23 Commits

Author SHA1 Message Date
e8ea347654 feat: organize administration as a settings tree 2026-07-30 17:42:06 +02:00
a758c8f2da perf(access): provide bounded reference search 2026-07-30 01:30:04 +02:00
6295cfa840 perf(access): batch tenant summary counts 2026-07-30 01:15:16 +02:00
b6c2c89adf feat: cache immutable auth principal summaries 2026-07-29 19:24:00 +02:00
6b0dd8beab feat: manage bounded automation service accounts 2026-07-29 17:48:35 +02:00
984a015704 perf: batch authorization and activity updates 2026-07-29 14:16:28 +02:00
3df4fc5bff fix: wrap administration fallback viewport 2026-07-28 22:50:11 +02:00
1b57df7753 Expose administration View surfaces 2026-07-28 21:04:54 +02:00
bf1ecc54b3 Expose credential envelope administration 2026-07-28 19:32:45 +02:00
fdfcfbb440 Resolve current principals for automation 2026-07-28 15:03:50 +02:00
f1d64d247e chore(access): bump version to 0.1.11 2026-07-22 03:43:27 +02:00
198803d5b9 feat(access): expose tenant-bounded people search 2026-07-22 03:02:05 +02:00
79781662e8 chore(access): bump version to 0.1.10 2026-07-21 20:48:22 +02:00
4075ac4d73 feat(access): derive authenticated baseline roles 2026-07-21 20:48:21 +02:00
21f77ffc50 refactor(webui): consume core access explanations 2026-07-21 13:18:36 +02:00
d0ef0531c0 refactor(webui): use central admin selections 2026-07-21 12:34:39 +02:00
c2917459a4 refactor(webui): centralize access table actions 2026-07-21 12:23:31 +02:00
fb1b573855 fix(release): align access runtime version 2026-07-21 12:23:12 +02:00
8c74e360d2 feat(auth): throttle password login attempts 2026-07-21 12:23:12 +02:00
e91935c03a Clean Access audit and test resources 2026-07-21 03:16:32 +02:00
01d082e552 Equalize Access login failure handling 2026-07-21 03:16:31 +02:00
90d8f65835 chore(i18n): refresh access translations 2026-07-20 20:03:19 +02:00
1c8e18e109 fix(permissions): preserve canonical granted scopes 2026-07-20 20:03:11 +02:00
53 changed files with 5208 additions and 260 deletions

View File

@@ -18,7 +18,13 @@ module directly. Access-side admin service helpers remain
here for users, groups, roles, system accounts, sessions, API keys, tenant here for users, groups, roles, system accounts, sessions, API keys, tenant
access enforcement, admin/audit lookup capabilities, tenant owner access enforcement, admin/audit lookup capabilities, tenant owner
provisioning, and governance-template materialization into access-owned groups provisioning, and governance-template materialization into access-owned groups
and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The and roles. The module also enforces narrowly declared managed
`RoleTemplate.default_authenticated` baselines. Their explicit permissions are
derived from the active manifest set for every authenticated tenant member,
without writing assignments during an authorization read. The optional role
row is a non-assignable administration projection, not the source of the
automatic grant. Domain permissions and resource policy remain separate checks.
Governance-template metadata CRUD lives in `govoplan-admin`. The
transitional administration WebUI route shell and transitional administration WebUI route shell and
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
system administration panels are contributed by `@govoplan/admin-webui` through system administration panels are contributed by `@govoplan/admin-webui` through
@@ -78,6 +84,14 @@ instead of importing access ORM models or backend dependency internals.
The detailed module boundary and serialization fields are documented in The detailed module boundary and serialization fields are documented in
[docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md). [docs/ACCESS_MODULE_BOUNDARY.md](docs/ACCESS_MODULE_BOUNDARY.md).
For scheduled and event-driven work, Access provides
`auth.automationPrincipalProvider`. Automation records store only an owner
account/membership reference and an explicit least-privilege scope grant, not
a session or API token. At delivery time Access rebuilds the principal from
current roles, groups, functions, and delegations and intersects that
authorization with the stored grant. Missing, inactive, moved, or
under-authorized owners fail closed before module work starts.
## WebUI Package ## WebUI Package
The repository root and `webui/` directory both expose the package The repository root and `webui/` directory both expose the package
@@ -113,3 +127,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.8", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-access" name = "govoplan-access"
version = "0.1.8" version = "0.1.11"
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives." description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.8", "govoplan-core>=0.1.11",
"redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
] ]

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

@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from sqlalchemy import func from sqlalchemy import case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
@@ -12,14 +12,99 @@ from govoplan_core.core.access import AccessAdministration
class SqlAccessAdministration(AccessAdministration): class SqlAccessAdministration(AccessAdministration):
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]: def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
db = _session(session) db = _session(session)
users, active_users = (
db.query(
func.count(User.id),
func.coalesce(
func.sum(case((User.is_active.is_(True), 1), else_=0)),
0,
),
)
.filter(User.tenant_id == tenant_id)
.one()
)
api_keys, active_api_keys = (
db.query(
func.count(ApiKey.id),
func.coalesce(
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
0,
),
)
.filter(ApiKey.tenant_id == tenant_id)
.one()
)
return { return {
"users": db.query(User).filter(User.tenant_id == tenant_id).count(), "users": int(users),
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(), "active_users": int(active_users),
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(), "groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(), "api_keys": int(api_keys),
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(), "active_api_keys": int(active_api_keys),
} }
def tenant_counts_many(
self,
session: object,
tenant_ids: Sequence[str],
) -> Mapping[str, Mapping[str, int]]:
ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id))
if not ids:
return {}
db = _session(session)
counts: dict[str, dict[str, int]] = {
tenant_id: {
"users": 0,
"active_users": 0,
"groups": 0,
"api_keys": 0,
"active_api_keys": 0,
}
for tenant_id in ids
}
user_rows = (
db.query(
User.tenant_id,
func.count(User.id),
func.coalesce(
func.sum(case((User.is_active.is_(True), 1), else_=0)),
0,
),
)
.filter(User.tenant_id.in_(ids))
.group_by(User.tenant_id)
.all()
)
for tenant_id, users, active_users in user_rows:
counts[tenant_id]["users"] = int(users)
counts[tenant_id]["active_users"] = int(active_users)
group_rows = (
db.query(Group.tenant_id, func.count(Group.id))
.filter(Group.tenant_id.in_(ids))
.group_by(Group.tenant_id)
.all()
)
for tenant_id, groups in group_rows:
counts[tenant_id]["groups"] = int(groups)
api_key_rows = (
db.query(
ApiKey.tenant_id,
func.count(ApiKey.id),
func.coalesce(
func.sum(case((ApiKey.revoked_at.is_(None), 1), else_=0)),
0,
),
)
.filter(ApiKey.tenant_id.in_(ids))
.group_by(ApiKey.tenant_id)
.all()
)
for tenant_id, api_keys, active_api_keys in api_key_rows:
counts[tenant_id]["api_keys"] = int(api_keys)
counts[tenant_id]["active_api_keys"] = int(active_api_keys)
return counts
def system_account_count(self, session: object) -> int: def system_account_count(self, session: object) -> int:
db = _session(session) db = _session(session)
return db.query(Account).count() return db.query(Account).count()

View File

@@ -24,7 +24,6 @@ from govoplan_access.backend.api.v1.admin_schemas import (
) )
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
collect_direct_user_roles, collect_direct_user_roles,
collect_system_roles,
collect_user_groups, collect_user_groups,
collect_user_scopes, collect_user_scopes,
) )
@@ -48,7 +47,11 @@ from govoplan_access.backend.db.models import (
) )
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
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes from govoplan_access.backend.permissions.catalog import (
effective_permission_count,
expand_scopes,
scopes_grant,
)
def _http_admin_error(exc: Exception) -> HTTPException: def _http_admin_error(exc: Exception) -> HTTPException:
@@ -358,44 +361,249 @@ def _user_item(
) )
def _system_account_item(session: Session, account: Account) -> SystemAccountItem: def _system_membership_rows(
memberships = ( session: Session,
account_ids: list[str],
) -> list[tuple[User, Tenant]]:
return (
session.query(User, Tenant) session.query(User, Tenant)
.join(Tenant, Tenant.id == User.tenant_id) .join(Tenant, Tenant.id == User.tenant_id)
.filter(User.account_id == account.id) .filter(User.account_id.in_(account_ids))
.order_by(Tenant.name.asc()) .order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
.all() .all()
) )
owner_ids_by_tenant = {
tenant.id: tenant_owner_user_ids(session, tenant.id)
for _, tenant in memberships def _memberships_by_account(
membership_rows: list[tuple[User, Tenant]],
) -> dict[str, list[tuple[User, Tenant]]]:
memberships_by_account: dict[str, list[tuple[User, Tenant]]] = defaultdict(list)
for user, tenant in membership_rows:
memberships_by_account[user.account_id].append((user, tenant))
return memberships_by_account
def _system_direct_roles_by_user(
session: Session,
user_ids: list[str],
) -> dict[str, list[Role]]:
roles_by_user: dict[str, list[Role]] = defaultdict(list)
if not user_ids:
return roles_by_user
rows = (
session.query(UserRoleAssignment.user_id, Role)
.join(Role, Role.id == UserRoleAssignment.role_id)
.filter(UserRoleAssignment.user_id.in_(user_ids))
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
.all()
)
for user_id, role in rows:
roles_by_user[user_id].append(role)
return roles_by_user
def _system_groups_by_user(
session: Session,
user_ids: list[str],
) -> dict[str, list[Group]]:
groups_by_user: dict[str, list[Group]] = defaultdict(list)
if not user_ids:
return groups_by_user
rows = (
session.query(UserGroupMembership.user_id, Group)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.user_id.in_(user_ids),
Group.is_active.is_(True),
)
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
.all()
)
for user_id, group in rows:
groups_by_user[user_id].append(group)
return groups_by_user
def _system_group_roles_by_user(
session: Session,
user_ids: list[str],
) -> dict[str, list[Role]]:
group_roles_by_user: dict[str, list[Role]] = defaultdict(list)
if not user_ids:
return group_roles_by_user
rows = (
session.query(UserGroupMembership.user_id, Role)
.join(
GroupRoleAssignment,
GroupRoleAssignment.group_id == UserGroupMembership.group_id,
)
.join(Role, Role.id == GroupRoleAssignment.role_id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.user_id.in_(user_ids),
Group.is_active.is_(True),
)
.all()
)
for user_id, role in rows:
group_roles_by_user[user_id].append(role)
return group_roles_by_user
def _system_owner_ids_by_tenant(
membership_rows: list[tuple[User, Tenant]],
*,
accounts_by_id: dict[str, Account],
direct_roles_by_user: dict[str, list[Role]],
group_roles_by_user: dict[str, list[Role]],
) -> dict[str, set[str]]:
owner_ids_by_tenant: dict[str, set[str]] = defaultdict(set)
for user, tenant in membership_rows:
account = accounts_by_id[user.account_id]
if not user.is_active or not account.is_active:
continue
effective_permissions = [
permission
for role in direct_roles_by_user[user.id] + group_roles_by_user[user.id]
for permission in (role.permissions or [])
]
if (
scopes_grant(effective_permissions, "admin:roles:write")
and scopes_grant(effective_permissions, "campaign:send")
):
owner_ids_by_tenant[tenant.id].add(user.id)
return owner_ids_by_tenant
def _system_roles_for_accounts(
session: Session,
account_ids: list[str],
) -> tuple[dict[str, list[Role]], dict[str, int]]:
system_roles_by_account: dict[str, list[Role]] = defaultdict(list)
system_role_ids: set[str] = set()
rows = (
session.query(SystemRoleAssignment.account_id, Role)
.join(Role, Role.id == SystemRoleAssignment.role_id)
.filter(
SystemRoleAssignment.account_id.in_(account_ids),
Role.tenant_id.is_(None),
)
.order_by(SystemRoleAssignment.account_id.asc(), Role.name.asc())
.all()
)
for account_id, role in rows:
system_roles_by_account[account_id].append(role)
system_role_ids.add(role.id)
return (
system_roles_by_account,
_system_role_assignment_counts(session, sorted(system_role_ids)),
)
def _system_membership_item(
user: User,
tenant: Tenant,
*,
roles_by_user: dict[str, list[Role]],
groups_by_user: dict[str, list[Group]],
owner_ids_by_tenant: dict[str, set[str]],
) -> dict[str, object]:
tenant_owner_ids = owner_ids_by_tenant[tenant.id]
return {
"tenant_id": tenant.id,
"tenant_name": tenant.name,
"user_id": user.id,
"is_active": user.is_active and tenant.is_active,
"role_ids": [role.id for role in roles_by_user[user.id]],
"group_ids": [group.id for group in groups_by_user[user.id]],
"is_owner": user.id in tenant_owner_ids,
"is_last_active_owner": (
user.id in tenant_owner_ids and len(tenant_owner_ids) == 1
),
} }
def _system_account_response_item(
session: Session,
account: Account,
*,
memberships: list[tuple[User, Tenant]],
roles_by_user: dict[str, list[Role]],
groups_by_user: dict[str, list[Group]],
owner_ids_by_tenant: dict[str, set[str]],
system_roles: list[Role],
system_role_counts: dict[str, int],
) -> SystemAccountItem:
return SystemAccountItem( return SystemAccountItem(
account_id=account.id, account_id=account.id,
email=account.email, email=account.email,
display_name=account.display_name, display_name=account.display_name,
is_active=account.is_active, is_active=account.is_active,
memberships=[ memberships=[
{ _system_membership_item(
"tenant_id": tenant.id, user,
"tenant_name": tenant.name, tenant,
"user_id": user.id, roles_by_user=roles_by_user,
"is_active": user.is_active and tenant.is_active, groups_by_user=groups_by_user,
"role_ids": [role.id for role in collect_direct_user_roles(session, user)], owner_ids_by_tenant=owner_ids_by_tenant,
"group_ids": [group.id for group in collect_user_groups(session, user)], )
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
"is_last_active_owner": (
user.id in owner_ids_by_tenant[tenant.id]
and len(owner_ids_by_tenant[tenant.id]) == 1
),
}
for user, tenant in memberships for user, tenant in memberships
], ],
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)], roles=[
_role_summary(
session,
role,
system_role_assignment_counts=system_role_counts,
)
for role in system_roles
],
last_login_at=account.last_login_at, last_login_at=account.last_login_at,
) )
def _system_account_items(
session: Session,
accounts: list[Account],
) -> list[SystemAccountItem]:
if not accounts:
return []
account_ids = [account.id for account in accounts]
accounts_by_id = {account.id: account for account in accounts}
membership_rows = _system_membership_rows(session, account_ids)
memberships_by_account = _memberships_by_account(membership_rows)
user_ids = [user.id for user, _tenant in membership_rows]
roles_by_user = _system_direct_roles_by_user(session, user_ids)
groups_by_user = _system_groups_by_user(session, user_ids)
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
owner_ids_by_tenant = _system_owner_ids_by_tenant(
membership_rows,
accounts_by_id=accounts_by_id,
direct_roles_by_user=roles_by_user,
group_roles_by_user=group_roles_by_user,
)
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
session,
account_ids,
)
return [
_system_account_response_item(
session,
account,
memberships=memberships_by_account[account.id],
roles_by_user=roles_by_user,
groups_by_user=groups_by_user,
owner_ids_by_tenant=owner_ids_by_tenant,
system_roles=system_roles_by_account[account.id],
system_role_counts=system_role_counts,
)
for account in accounts
]
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
return _system_account_items(session, [account])[0]
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem: def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
if accounts_by_user_id is not None: if accounts_by_user_id is not None:
account = accounts_by_user_id.get(item.user_id) account = accounts_by_user_id.get(item.user_id)

View File

@@ -193,7 +193,7 @@ class OrganizationUnitItem(BaseModel):
updated_at: datetime updated_at: datetime
class OrganizationUnitListResponse(BaseModel): class OrganizationUnitListResponse(PagedListResponse):
organization_units: list[OrganizationUnitItem] organization_units: list[OrganizationUnitItem]
@@ -232,7 +232,7 @@ class FunctionAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionListResponse(BaseModel): class FunctionListResponse(PagedListResponse):
functions: list[FunctionAdminItem] functions: list[FunctionAdminItem]
@@ -247,7 +247,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
updated_at: datetime updated_at: datetime
class ExternalFunctionRoleMappingListResponse(BaseModel): class ExternalFunctionRoleMappingListResponse(PagedListResponse):
mappings: list[ExternalFunctionRoleMappingItem] mappings: list[ExternalFunctionRoleMappingItem]
@@ -318,7 +318,7 @@ class FunctionAssignmentAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionAssignmentListResponse(BaseModel): class FunctionAssignmentListResponse(PagedListResponse):
assignments: list[FunctionAssignmentAdminItem] assignments: list[FunctionAssignmentAdminItem]
@@ -368,7 +368,7 @@ class FunctionDelegationAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionDelegationListResponse(BaseModel): class FunctionDelegationListResponse(PagedListResponse):
delegations: list[FunctionDelegationAdminItem] delegations: list[FunctionDelegationAdminItem]
@@ -714,7 +714,7 @@ class ConfigurationSafetyFieldItem(BaseModel):
storage: str storage: str
ui_managed: bool ui_managed: bool
risk: Literal["low", "medium", "high", "destructive"] risk: Literal["low", "medium", "high", "destructive"]
secret_handling: Literal["none", "reference_only", "env_only"] = "none" secret_handling: Literal["none", "reference_only", "env_only"] = "none" # noqa: S105 - policy vocabulary.
required_scopes: list[str] = Field(default_factory=list) required_scopes: list[str] = Field(default_factory=list)
dry_run_required: bool = False dry_run_required: bool = False
validation_required: bool = True validation_required: bool = True
@@ -876,6 +876,55 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
secret: str secret: str
class ServiceAccountItem(BaseModel):
id: str
tenant_id: str
name: str
description: str | None = None
scope_ceiling: list[str] = Field(default_factory=list)
is_active: bool
revision: int
created_by_account_id: str | None = None
updated_by_account_id: str | None = None
retired_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ServiceAccountListResponse(BaseModel):
items: list[ServiceAccountItem]
class ServiceAccountCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
scope_ceiling: list[str] = Field(
default_factory=list,
max_length=200,
)
class ServiceAccountUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
scope_ceiling: list[str] | None = Field(
default=None,
max_length=200,
)
is_active: bool | None = None
class ServiceAccountRetireRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
class AuditAdminItem(BaseModel): class AuditAdminItem(BaseModel):
id: str id: str
scope: Literal["tenant", "system"] = "tenant" scope: Literal["tenant", "system"] = "tenant"

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -50,7 +51,12 @@ from govoplan_core.settings import settings
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
from govoplan_access.backend.security.api_keys import authenticate_api_key from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.passwords import verify_password from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
from govoplan_access.backend.security.login_throttle import (
LoginThrottle,
LoginThrottleDecision,
build_login_throttle,
)
from govoplan_access.backend.security.sessions import ( from govoplan_access.backend.security.sessions import (
authenticate_session_token, authenticate_session_token,
collect_user_authorization_context, collect_user_authorization_context,
@@ -253,7 +259,9 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True)) .filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
.one_or_none() .one_or_none()
) )
if account is None or not verify_password(payload.password, account.password_hash): password_hash = account.password_hash if account is not None and account.password_hash else DUMMY_PASSWORD_HASH
password_matches = verify_password(payload.password, password_hash)
if account is None or not account.password_hash or not password_matches:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
query = ( query = (
@@ -269,10 +277,73 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
query = query.filter(Tenant.slug == payload.tenant_slug) query = query.filter(Tenant.slug == payload.tenant_slug)
row = query.order_by(Tenant.name.asc()).first() row = query.order_by(Tenant.name.asc()).first()
if row is None: if row is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership") # Keep every authentication failure generic so callers cannot infer
# whether an account exists but lacks an active tenant membership.
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
return account, row[0], row[1] return account, row[0], row[1]
@lru_cache(maxsize=1)
def _configured_login_throttle() -> LoginThrottle | None:
if not settings.auth_login_throttle_enabled:
return None
return build_login_throttle(
redis_url=settings.redis_url,
identity_limit=settings.auth_login_throttle_identity_limit,
client_limit=settings.auth_login_throttle_client_limit,
window_seconds=settings.auth_login_throttle_window_seconds,
redis_retry_seconds=settings.auth_login_throttle_redis_retry_seconds,
)
def _raise_login_throttled(decision: LoginThrottleDecision) -> None:
retry_after = max(1, decision.retry_after_seconds)
# The detail deliberately matches every credential failure. Status and
# Retry-After communicate endpoint throttling without disclosing whether
# the supplied identity exists or has an active membership.
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Invalid login",
headers={"Retry-After": str(retry_after)},
)
def _resolve_throttled_login_user(
session: Session,
payload: LoginRequest,
request: Request,
) -> tuple[Account, User, Tenant]:
throttle = _configured_login_throttle()
if throttle is None:
return _resolve_login_user(session, payload)
normalized_email = normalize_email(payload.email)
client_address = request.client.host if request.client else None
context = {
"normalized_email": normalized_email,
"tenant_slug": payload.tenant_slug,
"client_address": client_address,
}
decision = throttle.check(**context)
if not decision.allowed:
_raise_login_throttled(decision)
try:
resolved = _resolve_login_user(session, payload)
except HTTPException as exc:
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
decision = throttle.record_failure(**context)
if not decision.allowed:
_raise_login_throttled(decision)
raise
throttle.record_success(
normalized_email=normalized_email,
tenant_slug=payload.tenant_slug,
)
return resolved
def _extract_auth_token(request: Request) -> tuple[str | None, str]: def _extract_auth_token(request: Request) -> tuple[str | None, str]:
x_api_key = request.headers.get("x-api-key") x_api_key = request.headers.get("x-api-key")
if x_api_key: if x_api_key:
@@ -616,7 +687,7 @@ def _me_response(
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload) account, user, tenant = _resolve_throttled_login_user(session, payload, request)
identity_directory = _identity_directory_from_request(request) identity_directory = _identity_directory_from_request(request)
authorization_context = collect_user_authorization_context( authorization_context = collect_user_authorization_context(
session, session,

View File

@@ -57,6 +57,7 @@ from govoplan_access.backend.api.v1.admin_common import (
_set_system_memberships, _set_system_memberships,
_system_role_assignment_counts, _system_role_assignment_counts,
_system_account_item, _system_account_item,
_system_account_items,
_tenant_role_assignment_counts, _tenant_role_assignment_counts,
_user_item, _user_item,
) )
@@ -176,6 +177,7 @@ from govoplan_core.core.change_sequence import (
sequence_entries_since, sequence_entries_since,
sequence_watermark_is_expired, sequence_watermark_is_expired,
) )
from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
Account, Account,
ApiKey, ApiKey,
@@ -200,7 +202,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,
) )
@@ -358,6 +359,16 @@ def _record_access_change(
actor_id=principal.user.id, actor_id=principal.user.id,
payload=payload or {}, payload=payload or {},
) )
invalidate_auth_principals(
session,
tenant_id=tenant_id,
source_module=ACCESS_MODULE_ID,
resource_type=resource_type,
resource_id=resource_id,
actor_type="user",
actor_id=principal.user.id,
reason=operation,
)
def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None: def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
@@ -485,14 +496,42 @@ def _validate_parent_organization_unit(
raise AdminValidationError("Parent organization unit does not belong to the tenant.") raise AdminValidationError("Parent organization unit does not belong to the tenant.")
def _function_item(session: Session, item: Function) -> FunctionAdminItem: def _function_role_ids_by_function_id(
role_ids = [ session: Session,
row[0] function_ids: Iterable[str],
for row in session.query(FunctionRoleAssignment.role_id) ) -> dict[str, list[str]]:
.filter(FunctionRoleAssignment.function_id == item.id) requested = tuple(dict.fromkeys(function_ids))
.order_by(FunctionRoleAssignment.created_at.asc()) result: dict[str, list[str]] = {function_id: [] for function_id in requested}
if not requested:
return result
rows = (
session.query(
FunctionRoleAssignment.function_id,
FunctionRoleAssignment.role_id,
)
.filter(FunctionRoleAssignment.function_id.in_(requested))
.order_by(
FunctionRoleAssignment.function_id.asc(),
FunctionRoleAssignment.created_at.asc(),
)
.all() .all()
] )
for function_id, role_id in rows:
result.setdefault(function_id, []).append(role_id)
return result
def _function_item(
session: Session,
item: Function,
*,
role_ids_by_function_id: dict[str, list[str]] | None = None,
) -> FunctionAdminItem:
if role_ids_by_function_id is None:
role_ids_by_function_id = _function_role_ids_by_function_id(
session,
(item.id,),
)
return FunctionAdminItem( return FunctionAdminItem(
id=item.id, id=item.id,
tenant_id=item.tenant_id, tenant_id=item.tenant_id,
@@ -500,7 +539,7 @@ def _function_item(session: Session, item: Function) -> FunctionAdminItem:
slug=item.slug, slug=item.slug,
name=item.name, name=item.name,
description=item.description, description=item.description,
role_ids=role_ids, role_ids=role_ids_by_function_id.get(item.id, []),
delegable=item.delegable, delegable=item.delegable,
act_in_place_allowed=item.act_in_place_allowed, act_in_place_allowed=item.act_in_place_allowed,
is_active=item.is_active, is_active=item.is_active,
@@ -1267,17 +1306,26 @@ def deactivate_identity(
@router.get("/organization-units", response_model=OrganizationUnitListResponse) @router.get("/organization-units", response_model=OrganizationUnitListResponse)
def list_organization_units( def list_organization_units(
tenant_id: str | None = None, tenant_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:role:read")), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
): ):
tenant = _resolve_tenant(session, principal, tenant_id) tenant = _resolve_tenant(session, principal, tenant_id)
items = ( query = (
session.query(OrganizationUnit) session.query(OrganizationUnit)
.filter(OrganizationUnit.tenant_id == tenant.id) .filter(OrganizationUnit.tenant_id == tenant.id)
.order_by(OrganizationUnit.name.asc()) .order_by(OrganizationUnit.name.asc())
.all()
) )
return OrganizationUnitListResponse(organization_units=[_organization_unit_item(item) for item in items]) items, pagination = _page_query(
query,
page=page,
page_size=page_size,
)
return OrganizationUnitListResponse(
organization_units=[_organization_unit_item(item) for item in items],
**pagination,
)
@router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED) @router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
@@ -1395,6 +1443,8 @@ def deactivate_organization_unit(
def list_functions( def list_functions(
tenant_id: str | None = None, tenant_id: str | None = None,
organization_unit_id: str | None = None, organization_unit_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:role:read")), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
): ):
@@ -1402,8 +1452,26 @@ def list_functions(
query = session.query(Function).filter(Function.tenant_id == tenant.id) query = session.query(Function).filter(Function.tenant_id == tenant.id)
if organization_unit_id: if organization_unit_id:
query = query.filter(Function.organization_unit_id == organization_unit_id) query = query.filter(Function.organization_unit_id == organization_unit_id)
functions = query.order_by(Function.name.asc()).all() functions, pagination = _page_query(
return FunctionListResponse(functions=[_function_item(session, item) for item in functions]) query.order_by(Function.name.asc()),
page=page,
page_size=page_size,
)
role_ids_by_function_id = _function_role_ids_by_function_id(
session,
(item.id for item in functions),
)
return FunctionListResponse(
functions=[
_function_item(
session,
item,
role_ids_by_function_id=role_ids_by_function_id,
)
for item in functions
],
**pagination,
)
@router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED) @router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED)
@@ -1536,6 +1604,8 @@ def list_external_function_role_mappings(
tenant_id: str | None = None, tenant_id: str | None = None,
source_module: str | None = None, source_module: str | None = None,
function_id: str | None = None, function_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:role:read")), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
): ):
@@ -1545,23 +1615,57 @@ def list_external_function_role_mappings(
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip()) query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
if function_id: if function_id:
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip()) query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all() items, pagination = _page_query(
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items]) query.order_by(
ExternalFunctionRoleAssignment.source_module.asc(),
ExternalFunctionRoleAssignment.function_id.asc(),
),
page=page,
page_size=page_size,
)
return ExternalFunctionRoleMappingListResponse(
mappings=[_external_function_role_mapping_item(item) for item in items],
**pagination,
)
def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse: def _full_external_function_role_mappings_delta_response(
items = ( session: Session,
tenant: Tenant,
*,
cursor: tuple[int, int] | None = None,
limit: int = 500,
) -> ExternalFunctionRoleMappingListDeltaResponse:
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_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
)
)
page = cursor[0] if cursor is not None else 1
query = (
session.query(ExternalFunctionRoleAssignment) session.query(ExternalFunctionRoleAssignment)
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id) .filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()) .order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
.all() )
items, pagination, watermark, has_more = _full_delta_page(
query,
page=page,
page_size=limit,
scope="external-function-role-mappings",
snapshot_sequence=snapshot_sequence,
) )
return ExternalFunctionRoleMappingListDeltaResponse( return ExternalFunctionRoleMappingListDeltaResponse(
mappings=[_external_function_role_mapping_item(item) for item in items], mappings=[_external_function_role_mapping_item(item) for item in items],
deleted=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)), watermark=watermark,
has_more=False, has_more=has_more,
full=True, full=True,
**pagination,
) )
@@ -1621,8 +1725,17 @@ def list_external_function_role_mappings_delta(
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role: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(
return _full_external_function_role_mappings_delta_response(session, tenant) since,
scope="external-function-role-mappings",
)
if since is None or full_cursor is not None:
return _full_external_function_role_mappings_delta_response(
session,
tenant,
cursor=full_cursor,
limit=limit,
)
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit) return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
@@ -1736,6 +1849,8 @@ def list_function_assignments(
tenant_id: str | None = None, tenant_id: str | None = None,
account_id: str | None = None, account_id: str | None = None,
function_id: str | None = None, function_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:function:assign")), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")),
): ):
@@ -1745,8 +1860,15 @@ def list_function_assignments(
query = query.filter(FunctionAssignment.account_id == account_id) query = query.filter(FunctionAssignment.account_id == account_id)
if function_id: if function_id:
query = query.filter(FunctionAssignment.function_id == function_id) query = query.filter(FunctionAssignment.function_id == function_id)
assignments = query.order_by(FunctionAssignment.created_at.desc()).all() assignments, pagination = _page_query(
return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments]) query.order_by(FunctionAssignment.created_at.desc()),
page=page,
page_size=page_size,
)
return FunctionAssignmentListResponse(
assignments=[_function_assignment_item(item) for item in assignments],
**pagination,
)
@router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED) @router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED)
@@ -1889,6 +2011,8 @@ def deactivate_function_assignment(
def list_function_delegations( def list_function_delegations(
tenant_id: str | None = None, tenant_id: str | None = None,
account_id: str | None = None, account_id: str | None = 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_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")), principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")),
): ):
@@ -1896,8 +2020,15 @@ def list_function_delegations(
query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id) query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id)
if account_id: if account_id:
query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id)) query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id))
delegations = query.order_by(FunctionDelegation.created_at.desc()).all() delegations, pagination = _page_query(
return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations]) query.order_by(FunctionDelegation.created_at.desc()),
page=page,
page_size=page_size,
)
return FunctionDelegationListResponse(
delegations=[_function_delegation_item(item) for item in delegations],
**pagination,
)
@router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED) @router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED)
@@ -2982,6 +3113,25 @@ def _system_role_summaries_for_response(session: Session, roles: list[Role]) ->
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles] return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
def _system_roles_for_account_catalog(session: Session) -> list[Role]:
roles = (
session.query(Role)
.filter(Role.tenant_id.is_(None))
.order_by(Role.name.asc(), Role.id.asc())
.limit(1001)
.all()
)
if len(roles) > 1000:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
"The system role catalog exceeds 1000 entries. "
"Use the paginated system roles endpoint."
),
)
return roles
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse: 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()
@@ -3179,13 +3329,13 @@ _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_
def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> 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 = _system_roles_for_account_catalog(session)
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) 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 page = cursor[0] if cursor is not None else 1
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc()) 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) 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_items(session, accounts),
roles=_system_role_summaries_for_response(session, system_roles), roles=_system_role_summaries_for_response(session, system_roles),
deleted=[], deleted=[],
watermark=watermark, watermark=watermark,
@@ -3224,7 +3374,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_items(session, list(visible_accounts.values())),
roles=_system_role_summaries_for_response(session, list(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),
@@ -3256,11 +3406,11 @@ def list_system_accounts(
): ):
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 = _system_roles_for_account_catalog(session)
query = session.query(Account).order_by(Account.email.asc()) query = session.query(Account).order_by(Account.email.asc())
accounts, pagination = _page_query(query, page=page, page_size=page_size) 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_items(session, accounts),
roles=_system_role_summaries_for_response(session, system_roles), roles=_system_role_summaries_for_response(session, system_roles),
**pagination, **pagination,
) )

View File

@@ -0,0 +1,236 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_access.backend.api.v1.admin_common import _resolve_tenant
from govoplan_access.backend.api.v1.admin_schemas import (
ServiceAccountCreateRequest,
ServiceAccountItem,
ServiceAccountListResponse,
ServiceAccountRetireRequest,
ServiceAccountUpdateRequest,
)
from govoplan_access.backend.service_accounts import (
ServiceAccountConflictError,
ServiceAccountError,
ServiceAccountNotFoundError,
create_service_account,
get_service_account,
list_service_accounts,
retire_service_account,
update_service_account,
)
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.db.session import get_session
router = APIRouter(
prefix="/admin/service-accounts",
tags=["admin", "service-accounts"],
)
@router.get("", response_model=ServiceAccountListResponse)
def list_managed_service_accounts(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:read")
),
):
tenant = _resolve_tenant(session, principal, None)
return ServiceAccountListResponse(
items=[
_service_account_item(item)
for item in list_service_accounts(
session,
tenant_id=tenant.id,
)
]
)
@router.get(
"/{service_account_id}",
response_model=ServiceAccountItem,
)
def get_managed_service_account(
service_account_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:read")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = get_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
)
except ServiceAccountError as exc:
raise _service_account_http_error(exc) from exc
return _service_account_item(item)
@router.post(
"",
response_model=ServiceAccountItem,
status_code=status.HTTP_201_CREATED,
)
def create_managed_service_account(
payload: ServiceAccountCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = create_service_account(
session,
tenant=tenant,
principal=principal,
name=payload.name,
description=payload.description,
scope_ceiling=payload.scope_ceiling,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.created",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={
"name": item.name,
"scope_ceiling": list(item.scope_ceiling),
},
)
session.commit()
return _service_account_item(item)
@router.patch(
"/{service_account_id}",
response_model=ServiceAccountItem,
)
def update_managed_service_account(
service_account_id: str,
payload: ServiceAccountUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
changes = {
field: getattr(payload, field)
for field in payload.model_fields_set
if field != "expected_revision"
}
for field in ("name", "scope_ceiling", "is_active"):
if field in changes and changes[field] is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"{field} cannot be null",
)
try:
item = update_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
principal=principal,
expected_revision=payload.expected_revision,
changes=changes,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.updated",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={
"changed_fields": sorted(changes),
"revision": item.revision,
},
)
session.commit()
return _service_account_item(item)
@router.post(
"/{service_account_id}/retire",
response_model=ServiceAccountItem,
)
def retire_managed_service_account(
service_account_id: str,
payload: ServiceAccountRetireRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("access:service_account:write")
),
):
tenant = _resolve_tenant(session, principal, None)
try:
item = retire_service_account(
session,
tenant_id=tenant.id,
service_account_id=service_account_id,
principal=principal,
expected_revision=payload.expected_revision,
)
except (ServiceAccountError, PermissionError) as exc:
session.rollback()
raise _service_account_http_error(exc) from exc
audit_from_principal(
session,
principal,
action="service_account.retired",
scope="tenant",
object_type="service_account",
object_id=item.id,
details={"revision": item.revision},
)
session.commit()
return _service_account_item(item)
def _service_account_item(item: object) -> ServiceAccountItem:
return ServiceAccountItem.model_validate(
item,
from_attributes=True,
)
def _service_account_http_error(exc: Exception) -> HTTPException:
if isinstance(exc, ServiceAccountNotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
)
if isinstance(exc, ServiceAccountConflictError):
return HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=str(exc),
)
if isinstance(exc, PermissionError):
return HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
)
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
__all__ = ["router"]

View File

@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from collections.abc import Mapping
from dataclasses import dataclass, replace
from datetime import timedelta
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
@@ -15,15 +17,32 @@ from govoplan_core.core.access import (
PrincipalRef, PrincipalRef,
PrincipalResolver, PrincipalResolver,
) )
from govoplan_core.core.automation import (
AutomationPrincipalRequest,
AutomationPrincipalResolution,
)
from govoplan_core.core.identity import IdentityDirectory from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import OrganizationDirectory from govoplan_core.core.organizations import OrganizationDirectory
from govoplan_core.core.principal_cache import (
auth_principal_revision,
)
from govoplan_core.core.modules import AccessDecision from govoplan_core.core.modules import AccessDecision
from govoplan_core.core.registry import PlatformRegistry 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,
ServiceAccount,
Tenant,
User,
)
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
from govoplan_access.backend.auth.tokens import hash_secret
from govoplan_access.backend.security.api_keys import authenticate_api_key from govoplan_access.backend.security.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,
@@ -32,6 +51,7 @@ from govoplan_access.backend.security.sessions import (
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_core.security.time import ensure_aware_utc, utc_now
from govoplan_access.backend.permissions.catalog import 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
@@ -191,6 +211,15 @@ def _resolve_legacy_principal_context(
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")
cached = _cached_principal_context(
request,
session,
token=token,
source=source,
)
if cached is not None:
return cached
if source != "cookie": if source != "cookie":
context = _resolve_api_key_principal_context( context = _resolve_api_key_principal_context(
session, session,
@@ -200,7 +229,14 @@ def _resolve_legacy_principal_context(
organization_directory=organization_directory, organization_directory=organization_directory,
) )
if context is not None: if context is not None:
return context return _cache_resolved_principal_context(
session,
token=token,
context=context,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
context = _resolve_session_principal_context( context = _resolve_session_principal_context(
request, request,
@@ -212,11 +248,226 @@ def _resolve_legacy_principal_context(
organization_directory=organization_directory, organization_directory=organization_directory,
) )
if context is not None: if context is not None:
return context return _cache_resolved_principal_context(
session,
token=token,
context=context,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
def _cached_principal_context(
request: Request,
session: Session,
*,
token: str,
source: str,
) -> ResolvedPrincipalContext | None:
if not settings.auth_principal_cache_enabled:
return None
token_digest = hash_secret(token)
entry = principal_summary_cache.get(
token_digest,
session_ttl_seconds=settings.auth_principal_cache_session_ttl_seconds,
api_key_ttl_seconds=settings.auth_principal_cache_api_key_ttl_seconds,
)
if entry is None:
return None
before = auth_principal_revision(session, tenant_id=entry.principal.tenant_id)
if before != entry.revision:
principal_summary_cache.discard(token_digest)
return None
context = _rehydrate_cached_principal(
request,
session,
token_digest=token_digest,
source=source,
principal=entry.principal,
)
after = auth_principal_revision(session, tenant_id=entry.principal.tenant_id)
if context is None or after != before:
principal_summary_cache.discard(token_digest)
return None
return context
def _rehydrate_cached_principal(
request: Request,
session: Session,
*,
token_digest: str,
source: str,
principal: PrincipalRef,
) -> ResolvedPrincipalContext | None:
account = session.get(Account, principal.account_id)
user = session.get(User, principal.membership_id) if principal.membership_id else None
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
if (
not account
or not user
or not tenant
or not account.is_active
or not user.is_active
or not tenant.is_active
or user.account_id != account.id
or user.tenant_id != tenant.id
):
return None
if principal.auth_method == "api_key":
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
if (
api_key is None
or api_key.key_hash != token_digest
or api_key.revoked_at is not None
or api_key.user_id != user.id
or api_key.tenant_id != tenant.id
or _is_expired(api_key.expires_at, allow_none=True)
):
return None
_touch_auth_activity(
session,
api_key,
field="last_used_at",
)
return ResolvedPrincipalContext(
principal=principal,
account=account,
user=user,
tenant=tenant,
api_key=api_key,
)
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
if (
auth_session is None
or auth_session.token_hash != token_digest
or auth_session.revoked_at is not None
or auth_session.user_id != user.id
or auth_session.account_id != account.id
or auth_session.tenant_id != tenant.id
or _is_expired(auth_session.expires_at)
):
return None
if source == "cookie":
_verify_session_csrf(request, auth_session)
_touch_auth_activity(
session,
auth_session,
field="last_seen_at",
)
return ResolvedPrincipalContext(
principal=principal,
account=account,
user=user,
tenant=tenant,
auth_session=auth_session,
)
def _is_expired(value, *, allow_none: bool = False) -> bool:
expires_at = ensure_aware_utc(value)
return (not allow_none and expires_at is None) or (
expires_at is not None and expires_at < utc_now()
)
def _touch_auth_activity(
session: Session,
model: ApiKey | AuthSession,
*,
field: str,
) -> None:
now = utc_now()
previous = ensure_aware_utc(getattr(model, field))
interval = settings.auth_activity_touch_interval_seconds
if interval <= 0 or previous is None or now - previous >= timedelta(seconds=interval):
setattr(model, field, now)
session.add(model)
session.commit()
def _cache_resolved_principal_context(
session: Session,
*,
token: str,
context: ResolvedPrincipalContext,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext:
if not settings.auth_principal_cache_enabled:
return context
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
refreshed = _refresh_principal_context(
session,
context=context,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
after = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
if before == after:
principal_summary_cache.put(
hash_secret(token),
principal=refreshed.principal,
revision=after,
max_entries=settings.auth_principal_cache_max_entries,
)
return refreshed
def _refresh_principal_context(
session: Session,
*,
context: ResolvedPrincipalContext,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext:
idm_assignments, idm_roles = _principal_idm_context(
session,
user=context.user,
account=context.account,
tenant_id=context.tenant.id,
idm_directory=idm_directory,
organization_directory=organization_directory,
)
include_system = context.auth_session is not None
authorization_context = collect_user_authorization_context(
session,
context.user,
account=context.account,
include_system=include_system,
extra_roles=idm_roles,
)
scopes = authorization_context.scopes
auth_method = "session"
if context.api_key is not None:
scopes = intersect_api_key_scopes(scopes, context.api_key.scopes or [])
auth_method = "api_key"
principal = _build_principal_ref(
session,
account=context.account,
user=context.user,
tenant_id=context.tenant.id,
scopes=scopes,
auth_method=auth_method,
api_key=context.api_key,
auth_session=context.auth_session,
idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles,
authorization_context=authorization_context,
include_system_roles=include_system,
)
return replace(context, principal=principal)
def _resolve_api_key_principal_ref( def _resolve_api_key_principal_ref(
session: Session, session: Session,
*, *,
@@ -245,11 +496,19 @@ def _resolve_api_key_principal_context(
) -> ResolvedPrincipalContext | 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,
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
)
if api_key is None: if api_key is None:
return None return None
user = session.get(User, api_key.user_id) activity_touch_pending = session.is_modified(
account = session.get(Account, user.account_id) if user else None api_key,
include_collections=False,
)
user = api_key.user
account = user.account if user else None
tenant = session.get(Tenant, api_key.tenant_id) tenant = session.get(Tenant, api_key.tenant_id)
if ( if (
not user or not account or not tenant not user or not account or not tenant
@@ -273,7 +532,6 @@ def _resolve_api_key_principal_context(
extra_roles=idm_roles, extra_roles=idm_roles,
) )
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or []) effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
session.commit()
principal = _build_principal_ref( principal = _build_principal_ref(
session, session,
api_key=api_key, api_key=api_key,
@@ -287,6 +545,8 @@ def _resolve_api_key_principal_context(
extra_roles=idm_roles, extra_roles=idm_roles,
authorization_context=authorization_context, authorization_context=authorization_context,
) )
if activity_touch_pending:
session.commit()
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key) return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
@@ -322,11 +582,19 @@ def _resolve_session_principal_context(
identity_directory: IdentityDirectory | None, identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None, organization_directory: OrganizationDirectory | None,
) -> ResolvedPrincipalContext | None: ) -> ResolvedPrincipalContext | None:
auth_session = authenticate_session_token(session, token) auth_session = authenticate_session_token(
session,
token,
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
)
if auth_session is None: if auth_session is None:
return None return None
user = session.get(User, auth_session.user_id) activity_touch_pending = session.is_modified(
account = session.get(Account, auth_session.account_id) auth_session,
include_collections=False,
)
user = auth_session.user
account = auth_session.account
tenant = session.get(Tenant, auth_session.tenant_id) tenant = session.get(Tenant, auth_session.tenant_id)
if ( if (
not user or not account or not tenant not user or not account or not tenant
@@ -353,7 +621,6 @@ def _resolve_session_principal_context(
extra_roles=idm_roles, extra_roles=idm_roles,
) )
scopes = authorization_context.scopes scopes = authorization_context.scopes
session.commit()
principal = _build_principal_ref( principal = _build_principal_ref(
session, session,
auth_session=auth_session, auth_session=auth_session,
@@ -368,6 +635,8 @@ def _resolve_session_principal_context(
authorization_context=authorization_context, authorization_context=authorization_context,
include_system_roles=True, include_system_roles=True,
) )
if activity_touch_pending:
session.commit()
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session) return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
@@ -558,6 +827,368 @@ class AccessApiPrincipalProvider:
) )
class AccessAutomationPrincipalProvider:
"""Rebuild a trigger owner against current tenant authorization."""
def __init__(
self,
*,
idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> None:
self._idm_directory = idm_directory
self._identity_directory = identity_directory
self._organization_directory = organization_directory
def resolve_automation_principal(
self,
session: object,
*,
request: AutomationPrincipalRequest,
) -> AutomationPrincipalResolution:
if not isinstance(session, Session):
raise TypeError(
"Access automation principal resolution requires a "
"SQLAlchemy session"
)
if request.subject_kind == "service_account":
return _resolve_service_account_automation(
session,
request=request,
)
return _resolve_delegated_user_automation(
session,
request=request,
idm_directory=self._idm_directory,
identity_directory=self._identity_directory,
organization_directory=self._organization_directory,
)
def _resolve_delegated_user_automation(
session: Session,
*,
request: AutomationPrincipalRequest,
idm_directory: IdmDirectory | None,
identity_directory: IdentityDirectory | None,
organization_directory: OrganizationDirectory | None,
) -> AutomationPrincipalResolution:
account = session.get(Account, request.account_id)
user = session.get(User, request.membership_id)
tenant = session.get(Tenant, request.tenant_id)
if (
account is None
or user is None
or tenant is None
or not account.is_active
or not user.is_active
or not tenant.is_active
or user.account_id != account.id
or user.tenant_id != tenant.id
):
return _automation_denied(
request,
status="inactive_or_inconsistent",
reason=(
"The automation owner is inactive, missing, or no longer "
"belongs to the tenant."
),
)
idm_assignments, idm_roles = _principal_idm_context(
session,
user=user,
account=account,
tenant_id=request.tenant_id,
idm_directory=idm_directory,
organization_directory=organization_directory,
)
authorization_context = collect_user_authorization_context(
session,
user,
account=account,
include_system=False,
extra_roles=idm_roles,
)
granted_scopes, missing_scopes = _current_trigger_grants(
request.grant_scopes,
current_scopes=authorization_context.scopes,
)
if missing_scopes:
return _automation_denied(
request,
status="authorization_reduced",
reason=(
"The automation owner no longer has every scope granted "
"to this trigger."
),
granted_scopes=granted_scopes,
missing_scopes=missing_scopes,
)
principal_ref = _build_principal_ref(
session,
account=account,
user=user,
tenant_id=request.tenant_id,
scopes=list(granted_scopes),
auth_method="service_account",
idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles,
authorization_context=authorization_context,
include_system_roles=False,
)
principal_ref = replace(
principal_ref,
service_account_id=None,
acting_for_account_id=account.id,
)
return _automation_allowed(
session,
request=request,
principal_ref=principal_ref,
granted_scopes=granted_scopes,
)
def _resolve_service_account_automation(
session: Session,
*,
request: AutomationPrincipalRequest,
) -> AutomationPrincipalResolution:
item = session.get(ServiceAccount, request.service_account_id)
tenant = session.get(Tenant, request.tenant_id)
account = (
session.get(Account, item.account_id)
if item is not None
else None
)
user = (
session.get(User, item.membership_id)
if item is not None
else None
)
if (
item is None
or tenant is None
or account is None
or user is None
or item.tenant_id != request.tenant_id
or not item.is_active
or not tenant.is_active
or not account.is_active
or not user.is_active
or item.account_id != account.id
or item.membership_id != user.id
or user.account_id != account.id
or user.tenant_id != tenant.id
or account.auth_provider != "service_account"
or user.auth_provider != "service_account"
):
return _automation_denied(
request,
status="inactive_or_inconsistent",
reason=(
"The service account is inactive, missing, or no longer "
"belongs to the tenant."
),
)
granted_scopes, missing_scopes = _current_trigger_grants(
request.grant_scopes,
current_scopes=item.scope_ceiling,
)
if missing_scopes:
return _automation_denied(
request,
status="authorization_reduced",
reason=(
"The service account no longer has every scope granted "
"to this trigger."
),
granted_scopes=granted_scopes,
missing_scopes=missing_scopes,
)
principal_ref = PrincipalRef(
account_id=account.id,
membership_id=user.id,
tenant_id=tenant.id,
scopes=frozenset(granted_scopes),
auth_method="service_account",
service_account_id=item.id,
email=None,
display_name=item.name,
)
return _automation_allowed(
session,
request=request,
principal_ref=principal_ref,
granted_scopes=granted_scopes,
)
def _current_trigger_grants(
trigger_scopes: tuple[str, ...],
*,
current_scopes: list[str] | tuple[str, ...],
) -> tuple[tuple[str, ...], tuple[str, ...]]:
granted = tuple(
sorted(
{
required
for required in trigger_scopes
if scopes_grant_compatible(
current_scopes,
required,
)
}
)
)
missing = tuple(
sorted(set(trigger_scopes) - set(granted))
)
return granted, missing
def _automation_allowed(
session: Session,
*,
request: AutomationPrincipalRequest,
principal_ref: PrincipalRef,
granted_scopes: tuple[str, ...],
) -> AutomationPrincipalResolution:
api_principal = _api_principal_from_ref(
session,
principal_ref,
permission_evaluator=LegacyPermissionEvaluator(),
)
provenance = _automation_provenance(
request,
status="current_authorization_resolved",
current_principal={
"kind": request.subject_kind,
"account_id": principal_ref.account_id,
"membership_id": principal_ref.membership_id,
"service_account_id": principal_ref.service_account_id,
"role_ids": sorted(principal_ref.role_ids),
"group_ids": sorted(principal_ref.group_ids),
"function_assignment_ids": sorted(
principal_ref.function_assignment_ids
),
"delegation_ids": sorted(
principal_ref.delegation_ids
),
"granted_scopes": list(granted_scopes),
},
)
return AutomationPrincipalResolution(
allowed=True,
principal=api_principal,
granted_scopes=granted_scopes,
provenance=provenance,
)
def _automation_denied(
request: AutomationPrincipalRequest,
*,
status: str,
reason: str,
granted_scopes: tuple[str, ...] = (),
missing_scopes: tuple[str, ...] | None = None,
) -> AutomationPrincipalResolution:
missing = (
tuple(sorted(set(request.grant_scopes)))
if missing_scopes is None
else missing_scopes
)
return AutomationPrincipalResolution(
allowed=False,
reason=reason,
granted_scopes=granted_scopes,
missing_scopes=missing,
provenance=_automation_provenance(
request,
status=status,
current_principal=None,
),
)
def _automation_provenance(
request: AutomationPrincipalRequest,
*,
status: str,
current_principal: Mapping[str, object] | None,
) -> dict[str, object]:
return {
"contract_version": request.contract_version,
"authorization_artifact": {
"ref": request.authorization_ref,
},
"trigger_owner": _automation_trigger_owner(request),
"current_automation_principal": (
dict(current_principal)
if current_principal is not None
else None
),
"event_actor": _automation_context_actor(
request.context.get("event_actor")
),
"operator_override": _automation_context_actor(
request.context.get("operator_override")
),
"trigger_ref": _optional_context_text(
request.context.get("trigger_ref")
),
"delivery_ref": _optional_context_text(
request.context.get("delivery_ref")
),
"status": status,
}
def _automation_trigger_owner(
request: AutomationPrincipalRequest,
) -> dict[str, object]:
return {
"kind": request.subject_kind,
"tenant_id": request.tenant_id,
"account_id": request.account_id,
"membership_id": request.membership_id,
"service_account_id": request.service_account_id,
}
def _automation_context_actor(
value: object,
) -> dict[str, str] | None:
if not isinstance(value, Mapping):
return None
result = {
key: str(value[key]).strip()
for key in (
"kind",
"type",
"id",
"label",
"account_id",
"membership_id",
"service_account_id",
"reason",
)
if value.get(key) is not None
and str(value[key]).strip()
}
return result or None
def _optional_context_text(value: object) -> str | None:
if value is None:
return None
clean = str(value).strip()
return clean[:300] or None
def get_api_principal( def get_api_principal(
request: Request, request: Request,
session: Session = Depends(get_session), session: Session = Depends(get_session),

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass
from threading import Lock
from time import monotonic
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.principal_cache import AuthPrincipalRevision
@dataclass(frozen=True, slots=True)
class CachedPrincipal:
principal: PrincipalRef
revision: AuthPrincipalRevision
stored_at: float
class PrincipalSummaryCache:
"""A bounded process-local cache containing no ORM or secret objects."""
def __init__(self) -> None:
self._entries: OrderedDict[str, CachedPrincipal] = OrderedDict()
self._lock = Lock()
def get(
self,
token_digest: str,
*,
session_ttl_seconds: int,
api_key_ttl_seconds: int,
) -> CachedPrincipal | None:
with self._lock:
entry = self._entries.get(token_digest)
if entry is None:
return None
ttl = (
api_key_ttl_seconds
if entry.principal.auth_method == "api_key"
else session_ttl_seconds
)
if ttl <= 0 or monotonic() - entry.stored_at > ttl:
self._entries.pop(token_digest, None)
return None
self._entries.move_to_end(token_digest)
return entry
def put(
self,
token_digest: str,
*,
principal: PrincipalRef,
revision: AuthPrincipalRevision,
max_entries: int,
) -> None:
with self._lock:
self._entries[token_digest] = CachedPrincipal(
principal=principal,
revision=revision,
stored_at=monotonic(),
)
self._entries.move_to_end(token_digest)
while len(self._entries) > max(1, max_entries):
self._entries.popitem(last=False)
def discard(self, token_digest: str) -> None:
with self._lock:
self._entries.pop(token_digest, None)
def clear(self) -> None:
with self._lock:
self._entries.clear()
principal_summary_cache = PrincipalSummaryCache()
__all__ = [
"CachedPrincipal",
"PrincipalSummaryCache",
"principal_summary_cache",
]

View File

@@ -4,7 +4,18 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, JSON, text from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_access.backend.db.base import AccessBase, TimestampMixin from govoplan_access.backend.db.base import AccessBase, TimestampMixin
@@ -110,6 +121,91 @@ class User(AccessBase, TimestampMixin):
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan") auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan")
class ServiceAccount(AccessBase, TimestampMixin):
"""Managed non-login principal for current-authority automation."""
__tablename__ = "access_service_accounts"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"normalized_name",
name="uq_access_service_accounts_tenant_name",
),
UniqueConstraint(
"account_id",
name="uq_access_service_accounts_account",
),
UniqueConstraint(
"membership_id",
name="uq_access_service_accounts_membership",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=new_uuid,
)
tenant_id: Mapped[str] = mapped_column(
String(36),
nullable=False,
index=True,
)
account_id: Mapped[str] = mapped_column(
ForeignKey("access_accounts.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
membership_id: Mapped[str] = mapped_column(
ForeignKey("access_users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
normalized_name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
description: Mapped[str | None] = mapped_column(Text)
scope_ceiling: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
created_by_account_id: Mapped[str | None] = mapped_column(
ForeignKey("access_accounts.id", ondelete="SET NULL"),
nullable=True,
)
updated_by_account_id: Mapped[str | None] = mapped_column(
ForeignKey("access_accounts.id", ondelete="SET NULL"),
nullable=True,
)
retired_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
settings: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
class Group(AccessBase, TimestampMixin): class Group(AccessBase, TimestampMixin):
__tablename__ = "access_groups" __tablename__ = "access_groups"
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),) __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
@@ -358,6 +454,7 @@ __all__ = [
"IdentityAccountLink", "IdentityAccountLink",
"OrganizationUnit", "OrganizationUnit",
"Role", "Role",
"ServiceAccount",
"SystemRoleAssignment", "SystemRoleAssignment",
"Tenant", "Tenant",
"User", "User",

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 (
@@ -14,6 +15,7 @@ from govoplan_core.core.access import (
CAPABILITY_ACCESS_TENANT_PROVISIONER, CAPABILITY_ACCESS_TENANT_PROVISIONER,
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY, CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER, CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
@@ -33,12 +35,15 @@ 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
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
from govoplan_core.core.views import ViewSurface
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:
@@ -69,6 +74,8 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission("access:system_role:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "Access", "system"), _permission("access:system_role:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "Access", "system"),
_permission("access:system_setting:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "Access", "system"), _permission("access:system_setting:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "Access", "system"),
_permission("access:system_setting:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "Access", "system"), _permission("access:system_setting:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "Access", "system"),
_permission("access:system_credential:read", "View system credentials", "List instance-wide reusable credential envelopes without revealing secret values.", "Access", "system"),
_permission("access:system_credential:write", "Manage system credentials", "Create, update, and retire instance-wide reusable credential envelopes.", "Access", "system"),
_permission("access:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "Access", "system"), _permission("access:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "Access", "system"),
_permission("access:audit:read", "View system audit", "Read audit records across tenants.", "Access", "system"), _permission("access:audit:read", "View system audit", "Read audit records across tenants.", "Access", "system"),
_permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"), _permission("access:membership:read", "View memberships", "List tenant memberships and effective access.", "Tenant access", "tenant"),
@@ -87,8 +94,13 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
_permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"), _permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"),
_permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"), _permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"),
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"), _permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
_permission("access:service_account:read", "View service accounts", "List non-login automation principals and their current scope ceilings.", "Tenant access", "tenant"),
_permission("access:service_account:write", "Manage service accounts", "Create, update, suspend, and retire scope-bounded automation principals.", "Tenant access", "tenant"),
_permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"), _permission("access:setting:read", "View settings", "Read access and governance settings.", "Tenant access", "tenant"),
_permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"), _permission("access:setting:write", "Manage settings", "Update access and governance settings.", "Tenant access", "tenant"),
_permission("access:credential:read", "View credentials", "List reusable credential envelopes without revealing secret values.", "Tenant access", "tenant"),
_permission("access:credential:write", "Manage credentials", "Create, update, and retire reusable credential envelopes.", "Tenant access", "tenant"),
_permission("access:credential:manage_own", "Manage own credentials", "Manage reusable credentials owned by the current membership.", "Tenant access", "tenant"),
_permission("access:policy:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant access", "tenant"), _permission("access:policy:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant access", "tenant"),
_permission("access:policy:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant access", "tenant"), _permission("access:policy:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant access", "tenant"),
_permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"), _permission("access:governance:read", "View governance", "Inspect managed role and group templates.", "Access", "system"),
@@ -123,6 +135,8 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
"access:system_role:assign", "access:system_role:assign",
"access:system_setting:read", "access:system_setting:read",
"access:system_setting:write", "access:system_setting:write",
"access:system_credential:read",
"access:system_credential:write",
"access:governance:read", "access:governance:read",
"access:governance:write", "access:governance:write",
), ),
@@ -181,8 +195,12 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
"access:api_key:read", "access:api_key:read",
"access:api_key:create", "access:api_key:create",
"access:api_key:revoke", "access:api_key:revoke",
"access:service_account:read",
"access:service_account:write",
"access:setting:read", "access:setting:read",
"access:setting:write", "access:setting:write",
"access:credential:read",
"access:credential:write",
"access:policy:read", "access:policy:read",
"access:policy:write", "access:policy:write",
), ),
@@ -231,6 +249,7 @@ ADMIN_READ_SCOPES = (
"admin:groups:read", "admin:groups:read",
"admin:roles:read", "admin:roles:read",
"admin:api_keys:read", "admin:api_keys:read",
"access:service_account:read",
"admin:settings:read", "admin:settings:read",
"system:tenants:read", "system:tenants:read",
"system:accounts:read", "system:accounts:read",
@@ -242,6 +261,10 @@ ADMIN_READ_SCOPES = (
"access:account:read", "access:account:read",
"access:governance:read", "access:governance:read",
"access:function:read", "access:function:read",
"views:definition:read",
"views:assignment:read",
"views:system_definition:read",
"views:system_assignment:read",
) )
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = ( ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
@@ -477,6 +500,18 @@ def _api_principal_provider(context: ModuleContext) -> object:
return AccessApiPrincipalProvider() return AccessApiPrincipalProvider()
def _automation_principal_provider(context: ModuleContext) -> object:
from govoplan_access.backend.auth.dependencies import (
AccessAutomationPrincipalProvider,
)
return AccessAutomationPrincipalProvider(
idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _tenant_context_switcher(context: ModuleContext) -> object: def _tenant_context_switcher(context: ModuleContext) -> object:
del context del context
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
@@ -504,6 +539,15 @@ def _access_semantic_directory(context: ModuleContext) -> object:
) )
def _access_reference_options(context: ModuleContext) -> object:
del context
from govoplan_access.backend.reference_options import (
SqlAccessReferenceOptionProvider,
)
return SqlAccessReferenceOptionProvider()
def _optional_identity_directory(context: ModuleContext) -> IdentityDirectory | None: def _optional_identity_directory(context: ModuleContext) -> IdentityDirectory | None:
if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY): if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
return None return None
@@ -590,18 +634,39 @@ def _route_factory(context: ModuleContext):
from govoplan_access.backend.api.v1.auth import router as auth_router from govoplan_access.backend.api.v1.auth import router as auth_router
from govoplan_access.backend.api.v1.routes import router as access_admin_router from govoplan_access.backend.api.v1.routes import router as access_admin_router
from govoplan_access.backend.api.v1.service_accounts import (
router as service_account_router,
)
router = APIRouter() router = APIRouter()
router.include_router(auth_router) router.include_router(auth_router)
router.include_router(access_admin_router) router.include_router(access_admin_router)
router.include_router(service_account_router)
return router return router
def _people_search(context: ModuleContext) -> object:
from govoplan_access.backend.people_search import people_search_capability
return people_search_capability(context)
manifest = ModuleManifest( manifest = ModuleManifest(
id="access", id="access",
name="Access", name="Access",
version="0.1.8", version="0.1.11",
optional_dependencies=("identity", "organizations", "tenancy", "idm"), optional_dependencies=("identity", "organizations", "tenancy", "idm"),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
ModuleInterfaceProvider(
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
version="0.1.0",
),
ModuleInterfaceProvider(
name="auth.automation_principal",
version="0.2.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,
@@ -618,6 +683,7 @@ manifest = ModuleManifest(
access_models.User, access_models.User,
access_models.Group, access_models.Group,
access_models.Role, access_models.Role,
access_models.ServiceAccount,
access_models.OrganizationUnit, access_models.OrganizationUnit,
access_models.Function, access_models.Function,
access_models.FunctionRoleAssignment, access_models.FunctionRoleAssignment,
@@ -639,9 +705,28 @@ manifest = ModuleManifest(
package_name="@govoplan/access-webui", package_name="@govoplan/access-webui",
routes=(FrontendRoute(path="/admin", component="AdminPage", required_any=ADMIN_READ_SCOPES, order=900),), routes=(FrontendRoute(path="/admin", component="AdminPage", required_any=ADMIN_READ_SCOPES, order=900),),
nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),), nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
view_surfaces=(
ViewSurface(id="access.admin.system-tenants", module_id="access", kind="section", label="System tenants", order=10),
ViewSurface(id="access.admin.system-roles", module_id="access", kind="section", label="System roles", order=20),
ViewSurface(id="access.admin.system-users", module_id="access", kind="section", label="System users", order=50),
ViewSurface(id="access.admin.system-credentials", module_id="access", kind="section", label="System credentials", order=80),
ViewSurface(id="access.admin.tenant-roles", module_id="access", kind="section", label="Tenant roles", order=10),
ViewSurface(id="access.admin.tenant-function-mappings", module_id="access", kind="section", label="Function mappings", order=20),
ViewSurface(id="access.admin.tenant-groups", module_id="access", kind="section", label="Tenant groups", order=30),
ViewSurface(id="access.admin.tenant-users", module_id="access", kind="section", label="Tenant users", order=40),
ViewSurface(id="access.admin.tenant-credentials", module_id="access", kind="section", label="Tenant credentials", order=70),
ViewSurface(id="access.admin.tenant-api-keys", module_id="access", kind="section", label="Tenant API keys", order=80),
ViewSurface(id="access.admin.tenant-settings", module_id="access", kind="section", label="Tenant settings", order=90),
ViewSurface(id="access.admin.group-credentials", module_id="access", kind="section", label="Group credentials", order=30),
ViewSurface(id="access.admin.user-credentials", module_id="access", kind="section", label="User credentials", order=30),
ViewSurface(id="access.settings.credentials", module_id="access", kind="section", label="Personal credentials", order=30),
),
), ),
capability_factories={ capability_factories={
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider, CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
_automation_principal_provider
),
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver, CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator, CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher, CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher,
@@ -653,6 +738,8 @@ 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,
CAPABILITY_ACCESS_REFERENCE_OPTIONS: _access_reference_options,
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider, ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
}, },
documentation=ACCESS_DOCUMENTATION, documentation=ACCESS_DOCUMENTATION,

View File

@@ -0,0 +1,141 @@
"""managed automation service accounts
Revision ID: b6d9f2a5c8e1
Revises: 4a5b6c7d8e9f
Create Date: 2026-07-29 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b6d9f2a5c8e1"
down_revision = "4a5b6c7d8e9f"
branch_labels = None
depends_on = None
def upgrade() -> None:
if (
"access_service_accounts"
in sa.inspect(op.get_bind()).get_table_names()
):
return
op.create_table(
"access_service_accounts",
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("membership_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("normalized_name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column(
"created_by_account_id",
sa.String(length=36),
nullable=True,
),
sa.Column(
"updated_by_account_id",
sa.String(length=36),
nullable=True,
),
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
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_service_accounts_account_id_access_accounts"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_account_id"],
["access_accounts.id"],
name=op.f(
"fk_access_service_accounts_created_by_account_id_"
"access_accounts"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["membership_id"],
["access_users.id"],
name=op.f(
"fk_access_service_accounts_membership_id_access_users"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f(
"fk_access_service_accounts_tenant_id_core_scopes"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["updated_by_account_id"],
["access_accounts.id"],
name=op.f(
"fk_access_service_accounts_updated_by_account_id_"
"access_accounts"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_access_service_accounts"),
),
sa.UniqueConstraint(
"account_id",
name="uq_access_service_accounts_account",
),
sa.UniqueConstraint(
"membership_id",
name="uq_access_service_accounts_membership",
),
sa.UniqueConstraint(
"tenant_id",
"normalized_name",
name="uq_access_service_accounts_tenant_name",
),
)
for name, columns in (
(
"ix_access_service_accounts_account_id",
["account_id"],
),
(
"ix_access_service_accounts_is_active",
["is_active"],
),
(
"ix_access_service_accounts_membership_id",
["membership_id"],
),
(
"ix_access_service_accounts_retired_at",
["retired_at"],
),
(
"ix_access_service_accounts_tenant_id",
["tenant_id"],
),
):
op.create_index(name, "access_service_accounts", columns)
def downgrade() -> None:
if (
"access_service_accounts"
in sa.inspect(op.get_bind()).get_table_names()
):
op.drop_table("access_service_accounts")

View File

@@ -0,0 +1,141 @@
"""managed automation service accounts
Revision ID: b6d9f2a5c8e1
Revises: 4a5b6c7d8e9f
Create Date: 2026-07-29 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "b6d9f2a5c8e1"
down_revision = "4a5b6c7d8e9f"
branch_labels = None
depends_on = None
def upgrade() -> None:
if (
"access_service_accounts"
in sa.inspect(op.get_bind()).get_table_names()
):
return
op.create_table(
"access_service_accounts",
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("membership_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("normalized_name", sa.String(length=255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("scope_ceiling", sa.JSON(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column(
"created_by_account_id",
sa.String(length=36),
nullable=True,
),
sa.Column(
"updated_by_account_id",
sa.String(length=36),
nullable=True,
),
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
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_service_accounts_account_id_access_accounts"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["created_by_account_id"],
["access_accounts.id"],
name=op.f(
"fk_access_service_accounts_created_by_account_id_"
"access_accounts"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["membership_id"],
["access_users.id"],
name=op.f(
"fk_access_service_accounts_membership_id_access_users"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["tenant_id"],
["core_scopes.id"],
name=op.f(
"fk_access_service_accounts_tenant_id_core_scopes"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["updated_by_account_id"],
["access_accounts.id"],
name=op.f(
"fk_access_service_accounts_updated_by_account_id_"
"access_accounts"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_access_service_accounts"),
),
sa.UniqueConstraint(
"account_id",
name="uq_access_service_accounts_account",
),
sa.UniqueConstraint(
"membership_id",
name="uq_access_service_accounts_membership",
),
sa.UniqueConstraint(
"tenant_id",
"normalized_name",
name="uq_access_service_accounts_tenant_name",
),
)
for name, columns in (
(
"ix_access_service_accounts_account_id",
["account_id"],
),
(
"ix_access_service_accounts_is_active",
["is_active"],
),
(
"ix_access_service_accounts_membership_id",
["membership_id"],
),
(
"ix_access_service_accounts_retired_at",
["retired_at"],
),
(
"ix_access_service_accounts_tenant_id",
["tenant_id"],
),
):
op.create_index(name, "access_service_accounts", columns)
def downgrade() -> None:
if (
"access_service_accounts"
in sa.inspect(op.get_bind()).get_table_names()
):
op.drop_table("access_service_accounts")

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

@@ -100,7 +100,12 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
for alias in compatible_required_scopes(scope): for alias in compatible_required_scopes(scope):
if alias != scope: if alias != scope:
expanded.add(alias) expanded.add(alias)
if include_unknown and not matched: # Preserve explicitly granted scopes in the presentation set. A
# canonical module scope can still match its legacy compatibility
# alias when the providing module is not loaded; treating that match
# as proof that the original scope is known would otherwise replace
# the canonical grant with only the deprecated alias.
if include_unknown or scope in catalog:
expanded.add(scope) expanded.add(scope)
return sorted(expanded) return sorted(expanded)

View File

@@ -0,0 +1,262 @@
from __future__ import annotations
from collections.abc import Sequence
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.references import (
ReferenceOption,
ReferenceSearchPage,
ReferenceSearchRequest,
)
class SqlAccessReferenceOptionProvider:
"""Principal-aware, bounded Access directory search."""
def search_reference_options(
self,
session: object,
principal: object,
*,
request: ReferenceSearchRequest,
) -> ReferenceSearchPage:
db = _session(session)
tenant_id = str(request.tenant_id or "").strip()
if not tenant_id:
return ReferenceSearchPage()
limit = max(1, min(int(request.limit), 200))
offset = _cursor_offset(request.cursor)
selected = tuple(
dict.fromkeys(
str(value).strip()
for value in request.selected_values
if str(value).strip()
)
)[:200]
administrative = request.context.get("administrative") is True
query = str(request.query or "").strip().casefold()
if request.kind in {"user", "membership"}:
return _search_users(
db,
principal,
tenant_id=tenant_id,
kind=request.kind,
query=query,
selected=selected,
limit=limit,
offset=offset,
administrative=administrative,
)
if request.kind == "group":
return _search_groups(
db,
principal,
tenant_id=tenant_id,
query=query,
selected=selected,
limit=limit,
offset=offset,
administrative=administrative,
)
raise ValueError(f"Unsupported Access reference kind: {request.kind}")
def _search_users(
session: Session,
principal: object,
*,
tenant_id: str,
kind: str,
query: str,
selected: Sequence[str],
limit: int,
offset: int,
administrative: bool,
) -> ReferenceSearchPage:
value_column = User.id if kind == "membership" else User.account_id
base = (
session.query(User, Account)
.join(Account, Account.id == User.account_id)
.filter(User.tenant_id == tenant_id)
)
if not administrative:
account_id = str(getattr(principal, "account_id", "") or "")
if not account_id:
return ReferenceSearchPage()
base = base.filter(User.account_id == account_id)
selected_rows = (
base.filter(value_column.in_(selected)).all()
if selected
else []
)
search_query = base
if selected:
search_query = search_query.filter(value_column.notin_(selected))
if query:
search_query = search_query.filter(
or_(
func.lower(func.coalesce(User.display_name, "")).contains(
query,
autoescape=True,
),
func.lower(User.email).contains(query, autoescape=True),
func.lower(Account.email).contains(query, autoescape=True),
func.lower(value_column).contains(query, autoescape=True),
)
)
rows = (
search_query.order_by(
func.lower(func.coalesce(User.display_name, User.email)).asc(),
value_column.asc(),
)
.offset(offset)
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
options = [
_user_option(user, account, kind=kind)
for user, account in rows[:limit]
]
selected_by_value = {
_user_value(user, kind=kind): _user_option(user, account, kind=kind)
for user, account in selected_rows
}
options.extend(
selected_by_value[value]
for value in selected
if value in selected_by_value
)
return ReferenceSearchPage(
options=tuple(options),
next_cursor=f"offset:{offset + limit}" if has_more else None,
has_more=has_more,
)
def _search_groups(
session: Session,
principal: object,
*,
tenant_id: str,
query: str,
selected: Sequence[str],
limit: int,
offset: int,
administrative: bool,
) -> ReferenceSearchPage:
base = session.query(Group).filter(Group.tenant_id == tenant_id)
if not administrative:
permitted = tuple(
dict.fromkeys(
str(group_id)
for group_id in getattr(principal, "group_ids", ())
if str(group_id)
)
)
if not permitted:
return ReferenceSearchPage()
base = base.filter(Group.id.in_(permitted))
selected_rows = base.filter(Group.id.in_(selected)).all() if selected else []
search_query = base
if selected:
search_query = search_query.filter(Group.id.notin_(selected))
if query:
search_query = search_query.filter(
or_(
func.lower(Group.name).contains(query, autoescape=True),
func.lower(Group.slug).contains(query, autoescape=True),
func.lower(Group.id).contains(query, autoescape=True),
)
)
rows = (
search_query.order_by(func.lower(Group.name).asc(), Group.id.asc())
.offset(offset)
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
options = [_group_option(group) for group in rows[:limit]]
selected_by_value = {group.id: _group_option(group) for group in selected_rows}
options.extend(
selected_by_value[value]
for value in selected
if value in selected_by_value
)
return ReferenceSearchPage(
options=tuple(options),
next_cursor=f"offset:{offset + limit}" if has_more else None,
has_more=has_more,
)
def _user_value(user: User, *, kind: str) -> str:
return user.id if kind == "membership" else user.account_id
def _user_option(user: User, account: Account, *, kind: str) -> ReferenceOption:
inactive = not user.is_active or not account.is_active
value = _user_value(user, kind=kind)
description_parts = [
user.email,
"Inactive" if inactive else None,
]
return ReferenceOption(
value=value,
label=user.display_name or user.email or value,
description=" · ".join(
part for part in description_parts if part
) or None,
kind=kind,
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={
"tenant_id": user.tenant_id,
"membership_id": user.id,
"account_id": user.account_id,
},
)
def _group_option(group: Group) -> ReferenceOption:
inactive = not group.is_active
return ReferenceOption(
value=group.id,
label=group.name or group.id,
description="Inactive" if inactive else None,
kind="group",
availability="inactive" if inactive else "available",
disabled=inactive,
source_module="access",
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
)
def _cursor_offset(cursor: str | None) -> int:
if cursor is None:
return 0
prefix = "offset:"
if not cursor.startswith(prefix):
raise ValueError("Invalid reference search cursor.")
try:
offset = int(cursor[len(prefix):])
except ValueError as exc:
raise ValueError("Invalid reference search cursor.") from exc
if offset < 0:
raise ValueError("Invalid reference search cursor.")
return offset
def _session(session: object) -> Session:
if not isinstance(session, Session):
raise TypeError("Access reference search requires a SQLAlchemy Session")
return session
__all__ = ["SqlAccessReferenceOptionProvider"]

View File

@@ -1,9 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timedelta
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_access.backend.db.models import ApiKey, User from govoplan_access.backend.db.models import ApiKey, User
@@ -60,17 +60,36 @@ def create_api_key(
return CreatedApiKey(model=model, secret=secret) return CreatedApiKey(model=model, secret=secret)
def authenticate_api_key(session: Session, secret: str) -> ApiKey | None: def authenticate_api_key(
session: Session,
secret: str,
*,
touch_interval_seconds: int = 5 * 60,
) -> ApiKey | None:
prefix = api_key_prefix(secret) prefix = api_key_prefix(secret)
candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all() candidates = (
session.query(ApiKey)
.options(joinedload(ApiKey.user).joinedload(User.account))
.filter(
ApiKey.prefix == prefix,
ApiKey.revoked_at.is_(None),
)
.all()
)
now = utc_now() now = utc_now()
for candidate in candidates: for candidate in candidates:
expires_at = ensure_aware_utc(candidate.expires_at) expires_at = ensure_aware_utc(candidate.expires_at)
if expires_at and expires_at < now: if expires_at and expires_at < now:
continue continue
if verify_api_key(secret, candidate.key_hash): if verify_api_key(secret, candidate.key_hash):
candidate.last_used_at = now last_used_at = ensure_aware_utc(candidate.last_used_at)
session.add(candidate) if (
touch_interval_seconds <= 0
or last_used_at is None
or now - last_used_at >= timedelta(seconds=touch_interval_seconds)
):
candidate.last_used_at = now
session.add(candidate)
return candidate return candidate
return None return None
@@ -78,4 +97,3 @@ def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
def has_scope(api_key: ApiKey, required_scope: str) -> bool: def has_scope(api_key: ApiKey, required_scope: str) -> bool:
scopes = set(api_key.scopes or []) scopes = set(api_key.scopes or [])
return "*" in scopes or required_scope in scopes return "*" in scopes or required_scope in scopes

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

@@ -4,7 +4,7 @@ from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from collections.abc import Iterable from collections.abc import Iterable
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
@@ -20,7 +20,7 @@ from govoplan_access.backend.db.models import (
UserRoleAssignment, UserRoleAssignment,
) )
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
from govoplan_access.backend.permissions.catalog import expand_scopes from govoplan_access.backend.permissions.catalog import expand_scopes, role_templates_for_level
from govoplan_core.security.time import ensure_aware_utc, utc_now from govoplan_core.security.time import ensure_aware_utc, utc_now
SESSION_RANDOM_BYTES = 32 SESSION_RANDOM_BYTES = 32
@@ -104,17 +104,39 @@ def create_auth_session(
return CreatedSession(model=model, token=token, csrf_token=csrf_token) return CreatedSession(model=model, token=token, csrf_token=csrf_token)
def authenticate_session_token(session: Session, token: str) -> AuthSession | None: def authenticate_session_token(
session: Session,
token: str,
*,
touch_interval_seconds: int = 5 * 60,
) -> AuthSession | None:
token_hash = hash_session_token(token) token_hash = hash_session_token(token)
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none() model = (
session.query(AuthSession)
.options(
joinedload(AuthSession.user).joinedload(User.account),
joinedload(AuthSession.account),
)
.filter(
AuthSession.token_hash == token_hash,
AuthSession.revoked_at.is_(None),
)
.one_or_none()
)
if not model: if not model:
return None return None
now = utc_now() now = utc_now()
expires_at = ensure_aware_utc(model.expires_at) expires_at = ensure_aware_utc(model.expires_at)
if expires_at is None or expires_at < now: if expires_at is None or expires_at < now:
return None return None
model.last_seen_at = now last_seen_at = ensure_aware_utc(model.last_seen_at)
session.add(model) if (
touch_interval_seconds <= 0
or last_seen_at is None
or now - last_seen_at >= timedelta(seconds=touch_interval_seconds)
):
model.last_seen_at = now
session.add(model)
return model return model
@@ -183,6 +205,8 @@ def collect_user_roles(session: Session, user: User) -> list[Role]:
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in collect_function_roles(session, user): for role in collect_function_roles(session, user):
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in _materialized_default_authenticated_roles(session, user):
roles_by_id[role.id] = role
return list(roles_by_id.values()) return list(roles_by_id.values())
@@ -242,14 +266,24 @@ def collect_user_authorization_context(
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in extra_roles: for role in extra_roles:
roles_by_id[role.id] = role roles_by_id[role.id] = role
for role in _materialized_default_authenticated_roles(session, user):
roles_by_id[role.id] = role
tenant_roles = list(roles_by_id.values()) tenant_roles = list(roles_by_id.values())
system_roles = collect_system_roles(session, account) if include_system and account is not None else [] system_roles = collect_system_roles(session, account) if include_system and account is not None else []
default_slugs = _default_authenticated_slugs()
scopes = { scopes = {
scope scope
for role in [*tenant_roles, *system_roles] for role in tenant_roles
if role.slug not in default_slugs
for scope in (role.permissions or []) for scope in (role.permissions or [])
} }
scopes.update(
scope
for role in system_roles
for scope in (role.permissions or [])
)
scopes.update(_default_authenticated_scopes())
return UserAuthorizationContext( return UserAuthorizationContext(
tenant_roles=tenant_roles, tenant_roles=tenant_roles,
system_roles=system_roles, system_roles=system_roles,
@@ -261,15 +295,54 @@ def collect_user_authorization_context(
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]: def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
scopes: set[str] = set() scopes = _default_authenticated_scopes()
default_slugs = _default_authenticated_slugs()
for role in collect_user_roles(session, user): for role in collect_user_roles(session, user):
scopes.update(role.permissions or []) if role.slug not in default_slugs:
scopes.update(role.permissions or [])
if include_system and user.account: if include_system and user.account:
for role in collect_system_roles(session, user.account): for role in collect_system_roles(session, user.account):
scopes.update(role.permissions or []) scopes.update(role.permissions or [])
return expand_scopes(scopes) return expand_scopes(scopes)
def _default_authenticated_slugs() -> set[str]:
return {
template.slug
for template in role_templates_for_level("tenant")
if template.default_authenticated
}
def _default_authenticated_scopes() -> set[str]:
return {
scope
for template in role_templates_for_level("tenant")
if template.default_authenticated
for scope in template.permissions
}
def _materialized_default_authenticated_roles(
session: Session,
user: User,
) -> list[Role]:
"""Return the optional database projection without mutating auth reads."""
default_slugs = _default_authenticated_slugs()
if not default_slugs:
return []
return (
session.query(Role)
.filter(
Role.tenant_id == user.tenant_id,
Role.slug.in_(default_slugs),
)
.order_by(Role.name.asc(), Role.id.asc())
.all()
)
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]: def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
return ( return (
session.query(User, Tenant) session.query(User, Tenant)

View File

@@ -0,0 +1,283 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_access.backend.db.base import utcnow
from govoplan_access.backend.db.models import (
Account,
ServiceAccount,
Tenant,
User,
new_uuid,
)
from govoplan_core.auth import ApiPrincipal
class ServiceAccountError(ValueError):
pass
class ServiceAccountNotFoundError(ServiceAccountError):
pass
class ServiceAccountConflictError(ServiceAccountError):
pass
def list_service_accounts(
session: Session,
*,
tenant_id: str,
) -> list[ServiceAccount]:
return list(
session.scalars(
select(ServiceAccount)
.where(ServiceAccount.tenant_id == tenant_id)
.order_by(
ServiceAccount.normalized_name,
ServiceAccount.id,
)
)
)
def get_service_account(
session: Session,
*,
tenant_id: str,
service_account_id: str,
lock: bool = False,
) -> ServiceAccount:
query = select(ServiceAccount).where(
ServiceAccount.id == service_account_id,
ServiceAccount.tenant_id == tenant_id,
)
if lock:
query = query.with_for_update()
item = session.scalar(query)
if item is None:
raise ServiceAccountNotFoundError(
"Service account was not found"
)
return item
def create_service_account(
session: Session,
*,
tenant: Tenant,
principal: ApiPrincipal,
name: str,
description: str | None,
scope_ceiling: Iterable[str],
) -> ServiceAccount:
clean_name = _service_account_name(name)
scopes = _service_account_scopes(
principal,
scope_ceiling,
)
service_account_id = new_uuid()
internal_email = (
f"service-account-{service_account_id}@govoplan.invalid"
)
account = Account(
id=new_uuid(),
email=internal_email,
normalized_email=internal_email,
display_name=clean_name,
is_active=True,
auth_provider="service_account",
password_hash=None,
password_reset_required=False,
)
membership = User(
id=new_uuid(),
tenant_id=tenant.id,
account=account,
email=internal_email,
display_name=clean_name,
is_active=True,
is_tenant_admin=False,
auth_provider="service_account",
password_hash=None,
settings={"managed_service_account": service_account_id},
)
item = ServiceAccount(
id=service_account_id,
tenant_id=tenant.id,
account_id=account.id,
membership_id=membership.id,
name=clean_name,
normalized_name=_normalized_name(clean_name),
description=_optional_text(description),
scope_ceiling=list(scopes),
is_active=True,
revision=1,
created_by_account_id=principal.account_id,
updated_by_account_id=principal.account_id,
settings={},
)
session.add_all((account, membership, item))
try:
session.flush()
except IntegrityError as exc:
raise ServiceAccountConflictError(
"A service account with this name already exists"
) from exc
return item
def update_service_account(
session: Session,
*,
tenant_id: str,
service_account_id: str,
principal: ApiPrincipal,
expected_revision: int,
changes: Mapping[str, object],
) -> ServiceAccount:
item = get_service_account(
session,
tenant_id=tenant_id,
service_account_id=service_account_id,
lock=True,
)
if item.revision != expected_revision:
raise ServiceAccountConflictError(
"Service account changed on the server; reload before saving"
)
account = session.get(Account, item.account_id)
membership = session.get(User, item.membership_id)
if account is None or membership is None:
raise ServiceAccountConflictError(
"Service account backing identity is missing"
)
if "name" in changes:
clean_name = _service_account_name(str(changes["name"]))
item.name = clean_name
item.normalized_name = _normalized_name(clean_name)
account.display_name = clean_name
membership.display_name = clean_name
if "description" in changes:
value = changes["description"]
item.description = _optional_text(
str(value) if value is not None else None
)
if "scope_ceiling" in changes:
raw_scopes = changes["scope_ceiling"]
if not isinstance(raw_scopes, Iterable) or isinstance(
raw_scopes,
(str, bytes),
):
raise ServiceAccountError(
"Service account scope ceiling is invalid"
)
item.scope_ceiling = list(
_service_account_scopes(
principal,
(str(scope) for scope in raw_scopes),
)
)
if "is_active" in changes:
active = bool(changes["is_active"])
item.is_active = active
account.is_active = active
membership.is_active = active
item.retired_at = None if active else utcnow()
item.revision += 1
item.updated_by_account_id = principal.account_id
try:
session.flush()
except IntegrityError as exc:
raise ServiceAccountConflictError(
"A service account with this name already exists"
) from exc
return item
def retire_service_account(
session: Session,
*,
tenant_id: str,
service_account_id: str,
principal: ApiPrincipal,
expected_revision: int,
) -> ServiceAccount:
return update_service_account(
session,
tenant_id=tenant_id,
service_account_id=service_account_id,
principal=principal,
expected_revision=expected_revision,
changes={"is_active": False},
)
def _service_account_name(value: str) -> str:
clean = " ".join(value.split())
if not 1 <= len(clean) <= 255:
raise ServiceAccountError(
"Service account name must contain between 1 and 255 characters"
)
return clean
def _normalized_name(value: str) -> str:
return value.casefold()
def _optional_text(value: str | None) -> str | None:
if value is None:
return None
clean = value.strip()
if len(clean) > 4000:
raise ServiceAccountError(
"Service account description is too long"
)
return clean or None
def _service_account_scopes(
principal: ApiPrincipal,
values: Iterable[str],
) -> tuple[str, ...]:
scopes = tuple(
sorted(
{
str(value).strip()
for value in values
if str(value).strip()
}
)
)
if len(scopes) > 200:
raise ServiceAccountError(
"Service accounts support at most 200 scope grants"
)
denied = tuple(
scope for scope in scopes
if not principal.has(scope)
)
if denied:
raise PermissionError(
"Cannot grant service-account scopes outside the current "
f"administrator authority: {', '.join(denied)}"
)
return scopes
__all__ = [
"ServiceAccountConflictError",
"ServiceAccountError",
"ServiceAccountNotFoundError",
"create_service_account",
"get_service_account",
"list_service_accounts",
"retire_service_account",
"update_service_account",
]

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.administration import SqlAccessAdministration
from govoplan_access.backend.api.v1.admin_common import ( from govoplan_access.backend.api.v1.admin_common import (
_accounts_by_user_id, _accounts_by_user_id,
_group_member_ids_by_group_id, _group_member_ids_by_group_id,
@@ -13,7 +15,7 @@ from govoplan_access.backend.api.v1.admin_common import (
_roles_by_user_id, _roles_by_user_id,
_tenant_role_assignment_counts, _tenant_role_assignment_counts,
) )
from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment from govoplan_access.backend.db.models import Account, ApiKey, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -22,12 +24,15 @@ class AdminBatchHelperTests(unittest.TestCase):
self.engine = create_engine("sqlite:///:memory:") self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=self.engine) Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
def tearDown(self) -> None: def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine) Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def test_access_admin_batch_maps_related_rows(self) -> None: def test_access_admin_batch_maps_related_rows(self) -> None:
session = self.Session() session = self.session
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test") account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email) user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks") group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
@@ -53,6 +58,92 @@ class AdminBatchHelperTests(unittest.TestCase):
self.assertEqual([item.id for item in roles_by_user[user.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)}) self.assertEqual(role_counts, {role.id: (1, 1)})
def test_tenant_counts_many_uses_three_grouped_queries(self) -> None:
accounts = [
Account(
id=f"account-{index}",
email=f"user-{index}@example.test",
normalized_email=f"user-{index}@example.test",
)
for index in range(3)
]
users = [
User(
id="user-1",
tenant_id="tenant-1",
account_id=accounts[0].id,
email=accounts[0].email,
),
User(
id="user-2",
tenant_id="tenant-1",
account_id=accounts[1].id,
email=accounts[1].email,
is_active=False,
),
User(
id="user-3",
tenant_id="tenant-2",
account_id=accounts[2].id,
email=accounts[2].email,
),
]
self.session.add_all(
[
*accounts,
*users,
Group(id="group-1", tenant_id="tenant-1", slug="one", name="One"),
Group(id="group-2", tenant_id="tenant-2", slug="two", name="Two"),
ApiKey(
id="key-1",
tenant_id="tenant-1",
user_id="user-1",
name="Active",
prefix="active",
key_hash="hash-1",
),
ApiKey(
id="key-2",
tenant_id="tenant-1",
user_id="user-2",
name="Revoked",
prefix="revoked",
key_hash="hash-2",
revoked_at=datetime.now(UTC),
),
]
)
self.session.commit()
query_count = 0
def count_query(*_args: object) -> None:
nonlocal query_count
query_count += 1
event.listen(self.engine, "before_cursor_execute", count_query)
try:
counts = SqlAccessAdministration().tenant_counts_many(
self.session,
["tenant-1", "tenant-2", "tenant-empty"],
)
finally:
event.remove(self.engine, "before_cursor_execute", count_query)
self.assertEqual(3, query_count)
self.assertEqual(
{
"users": 2,
"active_users": 1,
"groups": 1,
"api_keys": 2,
"active_api_keys": 1,
},
counts["tenant-1"],
)
self.assertEqual(1, counts["tenant-2"]["users"])
self.assertEqual(0, counts["tenant-empty"]["users"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import MagicMock, patch
from govoplan_access.backend.security.api_keys import authenticate_api_key
from govoplan_access.backend.security.sessions import authenticate_session_token
class AuthenticationActivityTouchTests(TestCase):
def test_session_activity_is_touched_only_after_the_interval(self) -> None:
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
for age_seconds, should_touch in ((60, False), (301, True)):
with self.subTest(age_seconds=age_seconds):
model = SimpleNamespace(
expires_at=now + timedelta(hours=1),
last_seen_at=now - timedelta(seconds=age_seconds),
)
session = MagicMock()
(
session.query.return_value.options.return_value
.filter.return_value.one_or_none
).return_value = model
with (
patch(
"govoplan_access.backend.security.sessions.hash_session_token",
return_value="hashed",
),
patch(
"govoplan_access.backend.security.sessions.utc_now",
return_value=now,
),
):
result = authenticate_session_token(
session,
"token",
touch_interval_seconds=300,
)
self.assertIs(model, result)
if should_touch:
self.assertEqual(now, model.last_seen_at)
session.add.assert_called_once_with(model)
else:
self.assertEqual(
now - timedelta(seconds=age_seconds),
model.last_seen_at,
)
session.add.assert_not_called()
def test_api_key_activity_is_touched_only_after_the_interval(self) -> None:
now = datetime(2026, 7, 29, 10, 0, tzinfo=timezone.utc)
for age_seconds, should_touch in ((60, False), (301, True)):
with self.subTest(age_seconds=age_seconds):
model = SimpleNamespace(
expires_at=None,
last_used_at=now - timedelta(seconds=age_seconds),
key_hash="hashed",
)
session = MagicMock()
(
session.query.return_value.options.return_value
.filter.return_value.all
).return_value = [model]
with (
patch(
"govoplan_access.backend.security.api_keys.verify_api_key",
return_value=True,
),
patch(
"govoplan_access.backend.security.api_keys.utc_now",
return_value=now,
),
):
result = authenticate_api_key(
session,
"mm_test-token",
touch_interval_seconds=300,
)
self.assertIs(model, result)
if should_touch:
self.assertEqual(now, model.last_used_at)
session.add.assert_called_once_with(model)
else:
self.assertEqual(
now - timedelta(seconds=age_seconds),
model.last_used_at,
)
session.add.assert_not_called()
if __name__ == "__main__":
import unittest
unittest.main()

View File

@@ -1,13 +1,30 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import timedelta
from typing import Iterable from typing import Iterable
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from starlette.requests import Request from starlette.requests import Request
from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref from govoplan_access.backend.auth.dependencies import (
_extract_token,
_requires_csrf,
_resolve_legacy_principal_context,
_resolve_legacy_principal_ref,
)
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
from govoplan_access.backend.auth.tokens import hash_secret
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
from govoplan_core.core.principal_cache import invalidate_auth_principals
from govoplan_core.db.base import Base
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings from govoplan_core.settings import settings
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request: def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
@@ -22,6 +39,9 @@ def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ())
class AuthDependencyTests(unittest.TestCase): class AuthDependencyTests(unittest.TestCase):
def tearDown(self) -> None:
principal_summary_cache.clear()
def test_extract_token_prefers_explicit_api_key(self) -> None: def test_extract_token_prefers_explicit_api_key(self) -> None:
request = request_for(headers=[("authorization", "Bearer session-token")]) request = request_for(headers=[("authorization", "Bearer session-token")])
@@ -44,6 +64,98 @@ class AuthDependencyTests(unittest.TestCase):
self.assertEqual(raised.exception.status_code, 401) self.assertEqual(raised.exception.status_code, 401)
self.assertEqual(raised.exception.detail, "Missing API key or session token") self.assertEqual(raised.exception.detail, "Missing API key or session token")
def test_permission_revision_invalidates_cached_principal(self) -> None:
engine = create_engine("sqlite:///:memory:")
create_scope_tables(engine)
AccessBase.metadata.create_all(bind=engine)
Base.metadata.create_all(
bind=engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
SessionLocal = sessionmaker(bind=engine)
try:
with SessionLocal() as session:
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
account = Account(
id="account-1",
email="owner@example.test",
normalized_email="owner@example.test",
)
user = User(
id="user-1",
tenant_id=tenant.id,
account_id=account.id,
email=account.email,
)
role = Role(
id="role-1",
tenant_id=tenant.id,
slug="reader",
name="Reader",
permissions=["files:file:read"],
)
assignment = UserRoleAssignment(
tenant_id=tenant.id,
user_id=user.id,
role_id=role.id,
)
token = "ms_test-session-token"
auth_session = AuthSession(
id="session-1",
tenant_id=tenant.id,
user_id=user.id,
account_id=account.id,
token_hash=hash_secret(token),
expires_at=utc_now() + timedelta(hours=1),
)
session.add_all(
[tenant, account, user, role, assignment, auth_session]
)
session.commit()
request = request_for()
first = _resolve_legacy_principal_context(
request,
session,
authorization=f"Bearer {token}",
x_api_key=None,
)
self.assertIn("files:file:read", first.principal.scopes)
role.permissions = ["files:file:write"]
session.add(role)
invalidate_auth_principals(
session,
tenant_id=tenant.id,
source_module="access",
resource_type="role",
resource_id=role.id,
)
session.commit()
second = _resolve_legacy_principal_context(
request,
session,
authorization=f"Bearer {token}",
x_api_key=None,
)
self.assertNotIn("files:file:read", second.principal.scopes)
self.assertIn("files:file:write", second.principal.scopes)
finally:
AccessBase.metadata.drop_all(bind=engine)
scope_registry.metadata.drop_all(bind=engine)
Base.metadata.drop_all(
bind=engine,
tables=[
ChangeSequenceEntry.__table__,
ChangeSequenceRetentionFloor.__table__,
],
)
engine.dispose()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,295 @@
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.auth.dependencies import (
AccessAutomationPrincipalProvider,
)
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import (
Account,
ServiceAccount,
User,
)
from govoplan_access.backend.manifest import manifest
from govoplan_access.backend.security.sessions import (
UserAuthorizationContext,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
from govoplan_core.core.automation import AutomationPrincipalRequest
from govoplan_core.tenancy.scope import (
Tenant,
create_scope_tables,
scope_registry,
)
class AutomationPrincipalTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
AccessBase.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.account = Account(
id="account-1",
email="owner@example.test",
normalized_email="owner@example.test",
)
self.tenant = Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
)
self.user = User(
id="user-1",
tenant_id=self.tenant.id,
account_id=self.account.id,
email=self.account.email,
)
self.session.add_all([self.tenant, self.account, self.user])
self.session.commit()
self.provider = AccessAutomationPrincipalProvider()
def tearDown(self) -> None:
self.session.close()
AccessBase.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _request(self) -> AutomationPrincipalRequest:
return AutomationPrincipalRequest(
tenant_id=self.tenant.id,
account_id=self.account.id,
membership_id=self.user.id,
authorization_ref="dataflow-trigger:1",
grant_scopes=(
"dataflow:pipeline:run",
"datasources:catalogue:read",
),
context={
"trigger_ref": "dataflow-trigger:1",
"delivery_ref": "dataflow-delivery:1",
"event_actor": {
"type": "user",
"id": "event-user-1",
},
"operator_override": {
"type": "user",
"id": "operator-1",
"reason": "approved replay",
},
},
)
def test_resolution_intersects_trigger_grant_with_current_scopes(self) -> None:
context = UserAuthorizationContext(
tenant_roles=[],
system_roles=[],
groups=[],
function_assignment_ids=(),
function_delegation_ids=(),
scopes=[
"dataflow:pipeline:run",
"datasources:catalogue:read",
"system:settings:write",
],
)
with patch(
"govoplan_access.backend.auth.dependencies."
"collect_user_authorization_context",
return_value=context,
):
result = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertTrue(result.allowed)
self.assertIsInstance(result.principal, ApiPrincipal)
self.assertEqual(
frozenset(
{
"dataflow:pipeline:run",
"datasources:catalogue:read",
}
),
result.principal.scopes,
)
self.assertIsNone(
result.principal.principal.service_account_id
)
self.assertEqual(
self.account.id,
result.principal.principal.acting_for_account_id,
)
self.assertNotIn(
"system:settings:write",
result.principal.scopes,
)
self.assertEqual(
"delegated_user",
result.provenance["trigger_owner"]["kind"],
)
self.assertEqual(
"event-user-1",
result.provenance["event_actor"]["id"],
)
self.assertEqual(
"operator-1",
result.provenance["operator_override"]["id"],
)
self.assertEqual(
self.account.id,
result.provenance[
"current_automation_principal"
]["account_id"],
)
def test_revoked_scope_and_suspended_owner_fail_closed(self) -> None:
context = UserAuthorizationContext(
tenant_roles=[],
system_roles=[],
groups=[],
function_assignment_ids=(),
function_delegation_ids=(),
scopes=["dataflow:pipeline:run"],
)
with patch(
"govoplan_access.backend.auth.dependencies."
"collect_user_authorization_context",
return_value=context,
):
result = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertFalse(result.allowed)
self.assertEqual(
("datasources:catalogue:read",),
result.missing_scopes,
)
self.account.is_active = False
self.session.flush()
suspended = self.provider.resolve_automation_principal(
self.session,
request=self._request(),
)
self.assertFalse(suspended.allowed)
self.assertEqual(
"inactive_or_inconsistent",
suspended.provenance["status"],
)
def test_service_account_resolution_uses_current_scope_ceiling(self) -> None:
account = Account(
id="service-account-backing",
email="service@example.invalid",
normalized_email="service@example.invalid",
display_name="Import worker",
auth_provider="service_account",
)
membership = User(
id="service-membership",
tenant_id=self.tenant.id,
account_id=account.id,
email=account.email,
display_name=account.display_name,
auth_provider="service_account",
)
service_account = ServiceAccount(
id="service-1",
tenant_id=self.tenant.id,
account_id=account.id,
membership_id=membership.id,
name="Import worker",
normalized_name="import worker",
scope_ceiling=[
"dataflow:pipeline:run",
"datasources:catalogue:read",
"system:settings:write",
],
is_active=True,
revision=1,
settings={},
)
self.session.add_all(
(account, membership, service_account)
)
self.session.flush()
request = AutomationPrincipalRequest.service_account(
tenant_id=self.tenant.id,
service_account_id=service_account.id,
authorization_ref="dataflow-trigger:service",
grant_scopes=(
"dataflow:pipeline:run",
"datasources:catalogue:read",
),
)
result = self.provider.resolve_automation_principal(
self.session,
request=request,
)
self.assertTrue(result.allowed)
self.assertEqual(
service_account.id,
result.principal.principal.service_account_id,
)
self.assertEqual(
frozenset(request.grant_scopes),
result.principal.scopes,
)
self.assertNotIn(
"system:settings:write",
result.principal.scopes,
)
self.assertEqual(
"service_account",
result.provenance["trigger_owner"]["kind"],
)
service_account.scope_ceiling = [
"dataflow:pipeline:run"
]
self.session.flush()
reduced = self.provider.resolve_automation_principal(
self.session,
request=request,
)
self.assertFalse(reduced.allowed)
self.assertEqual(
("datasources:catalogue:read",),
reduced.missing_scopes,
)
service_account.is_active = False
self.session.flush()
inactive = self.provider.resolve_automation_principal(
self.session,
request=request,
)
self.assertFalse(inactive.allowed)
self.assertEqual(
"inactive_or_inconsistent",
inactive.provenance["status"],
)
def test_manifest_registers_automation_resolution(self) -> None:
self.assertIn(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
manifest.capability_factories,
)
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,7 +15,7 @@ class OptionalTenancyContractTests(unittest.TestCase):
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.8", dependencies) self.assertIn("govoplan-core>=0.1.11", dependencies)
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies) self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies)) self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))

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

@@ -24,6 +24,12 @@ class PermissionCatalogContractTests(unittest.TestCase):
self.assertIn("files:upload", scopes) self.assertIn("files:upload", scopes)
self.assertIn("mail_servers:test", scopes) self.assertIn("mail_servers:test", scopes)
def test_expand_scopes_preserves_explicit_canonical_scope_with_legacy_alias(self) -> None:
scopes = access_catalog.expand_scopes(("files:file:read",))
self.assertIn("files:file:read", scopes)
self.assertIn("files:read", scopes)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_access.backend.reference_options import (
SqlAccessReferenceOptionProvider,
)
from govoplan_core.core.references import ReferenceSearchRequest
from govoplan_core.db.base import Base
class AccessReferenceOptionProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[Account.__table__, User.__table__, Group.__table__],
)
self.session = Session(self.engine)
for index in range(120):
account = Account(
id=f"account-{index:03}",
email=f"person-{index:03}@example.test",
normalized_email=f"person-{index:03}@example.test",
)
self.session.add(account)
self.session.add(
User(
id=f"membership-{index:03}",
tenant_id="tenant-1",
account_id=account.id,
email=account.email,
display_name=f"Person {index:03}",
)
)
for index in range(75):
self.session.add(
Group(
id=f"group-{index:03}",
tenant_id="tenant-1",
slug=f"group-{index:03}",
name=f"Group {index:03}",
)
)
self.session.commit()
self.provider = SqlAccessReferenceOptionProvider()
self.admin = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-000",
group_ids=frozenset(),
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_large_directory_search_is_bounded_and_paged(self) -> None:
first = self.provider.search_reference_options(
self.session,
self.admin,
request=ReferenceSearchRequest(
kind="membership",
tenant_id="tenant-1",
limit=25,
context={"administrative": True},
),
)
second = self.provider.search_reference_options(
self.session,
self.admin,
request=ReferenceSearchRequest(
kind="membership",
tenant_id="tenant-1",
limit=25,
cursor=first.next_cursor,
context={"administrative": True},
),
)
self.assertEqual(25, len(first.options))
self.assertTrue(first.has_more)
self.assertEqual("offset:25", first.next_cursor)
self.assertEqual("membership-000", first.options[0].value)
self.assertEqual("membership-025", second.options[0].value)
def test_search_and_selected_values_do_not_materialize_the_directory(self) -> None:
page = self.provider.search_reference_options(
self.session,
self.admin,
request=ReferenceSearchRequest(
kind="membership",
tenant_id="tenant-1",
query="PERSON 119",
selected_values=("membership-005", "removed-membership"),
limit=10,
context={"administrative": True},
),
)
self.assertEqual(
["membership-119", "membership-005"],
[option.value for option in page.options],
)
self.assertFalse(page.has_more)
def test_non_administrators_only_search_their_permitted_references(self) -> None:
principal = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-004",
group_ids=frozenset({"group-007"}),
)
users = self.provider.search_reference_options(
self.session,
principal,
request=ReferenceSearchRequest(
kind="user",
tenant_id="tenant-1",
),
)
groups = self.provider.search_reference_options(
self.session,
principal,
request=ReferenceSearchRequest(
kind="group",
tenant_id="tenant-1",
),
)
self.assertEqual(["account-004"], [option.value for option in users.options])
self.assertEqual(["group-007"], [option.value for option in groups.options])
def test_invalid_cursor_is_rejected(self) -> None:
with self.assertRaisesRegex(ValueError, "Invalid reference search cursor"):
self.provider.search_reference_options(
self.session,
self.admin,
request=ReferenceSearchRequest(
kind="group",
tenant_id="tenant-1",
cursor="page:2",
context={"administrative": True},
),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,202 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.base import AccessBase
from govoplan_access.backend.db.models import (
Account,
ApiKey,
AuthSession,
User,
)
from govoplan_access.backend.service_accounts import (
ServiceAccountConflictError,
create_service_account,
retire_service_account,
update_service_account,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.tenancy.scope import (
Tenant,
create_scope_tables,
scope_registry,
)
class ServiceAccountTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
AccessBase.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.account = Account(
id="account-1",
email="admin@example.test",
normalized_email="admin@example.test",
)
self.user = User(
id="user-1",
tenant_id=self.tenant.id,
account_id=self.account.id,
email=self.account.email,
)
self.session.add_all(
(self.tenant, self.account, self.user)
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
AccessBase.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _principal(
self,
scopes: frozenset[str] = frozenset({"tenant:*"}),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=self.account.id,
membership_id=self.user.id,
tenant_id=self.tenant.id,
scopes=scopes,
),
account=self.account,
user=self.user,
)
def test_create_builds_a_non_login_identity_without_secrets(self) -> None:
item = create_service_account(
self.session,
tenant=self.tenant,
principal=self._principal(),
name=" Monthly import ",
description="Runs the governed monthly import.",
scope_ceiling=(
"dataflow:pipeline:run",
"datasources:catalogue:read",
),
)
backing_account = self.session.get(
Account,
item.account_id,
)
membership = self.session.get(
User,
item.membership_id,
)
self.assertEqual("Monthly import", item.name)
self.assertEqual(
[
"dataflow:pipeline:run",
"datasources:catalogue:read",
],
item.scope_ceiling,
)
self.assertEqual(
"service_account",
backing_account.auth_provider,
)
self.assertIsNone(backing_account.password_hash)
self.assertEqual(
"service_account",
membership.auth_provider,
)
self.assertIsNone(membership.password_hash)
self.assertEqual(
0,
self.session.query(ApiKey)
.filter(ApiKey.user_id == membership.id)
.count(),
)
self.assertEqual(
0,
self.session.query(AuthSession)
.filter(AuthSession.user_id == membership.id)
.count(),
)
def test_scope_escalation_and_stale_updates_fail_closed(self) -> None:
principal = self._principal(
frozenset({"dataflow:pipeline:run"})
)
with self.assertRaises(PermissionError):
create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Escalating worker",
description=None,
scope_ceiling=("system:settings:write",),
)
item = create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Bounded worker",
description=None,
scope_ceiling=("dataflow:pipeline:run",),
)
updated = update_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
changes={"description": "Updated"},
)
self.assertEqual(2, updated.revision)
with self.assertRaises(ServiceAccountConflictError):
update_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
changes={"description": "Stale"},
)
def test_retirement_revokes_the_backing_principal(self) -> None:
principal = self._principal()
item = create_service_account(
self.session,
tenant=self.tenant,
principal=principal,
name="Retired worker",
description=None,
scope_ceiling=("dataflow:pipeline:run",),
)
retired = retire_service_account(
self.session,
tenant_id=self.tenant.id,
service_account_id=item.id,
principal=principal,
expected_revision=1,
)
self.assertFalse(retired.is_active)
self.assertIsNotNone(retired.retired_at)
self.assertFalse(
self.session.get(Account, retired.account_id).is_active
)
self.assertFalse(
self.session.get(User, retired.membership_id).is_active
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.8", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -13,11 +13,11 @@
} }
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.8", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1" "react-router-dom": ">=7.18.2 <8"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {

View File

@@ -1,5 +1,13 @@
import type { ApiSettings, DeltaDeletedItem, PrivacyRetentionPolicy, TenantAdminItem } from "@govoplan/core-webui"; import type {
import { apiFetch, apiGetList, apiPath, apiQuery } from "@govoplan/core-webui"; AccessDecisionProvenanceItem as CoreAccessDecisionProvenanceItem,
ApiSettings,
DeltaDeletedItem,
PrivacyRetentionPolicy,
ResourceAccessExplanationOptions,
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
TenantAdminItem
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui"; export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
export type { export type {
AdminOverview, AdminOverview,
@@ -101,12 +109,7 @@ export type AccessScopeExplanationItem = {
sources: AccessRoleSourceItem[]; sources: AccessRoleSourceItem[];
}; };
export type AccessDecisionProvenanceItem = { export type AccessDecisionProvenanceItem = Omit<CoreAccessDecisionProvenanceItem, "details"> & {
kind: string;
id?: string | null;
label?: string | null;
tenant_id?: string | null;
source?: string | null;
details: Record<string, unknown>; details: Record<string, unknown>;
}; };
@@ -136,13 +139,7 @@ export type UserAccessExplanationResponse = {
function_facts: FunctionFactExplanationItem[]; function_facts: FunctionFactExplanationItem[];
}; };
export type ResourceAccessExplanationResponse = { export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationResponse<UserAdminItem, AccessDecisionProvenanceItem>;
user: UserAdminItem;
resource_type: string;
resource_id: string;
action: string;
provenance: AccessDecisionProvenanceItem[];
};
export type SystemAccountItem = { export type SystemAccountItem = {
account_id: string; account_id: string;
@@ -356,15 +353,9 @@ export function fetchUserAccessExplanation(settings: ApiSettings, userId: string
export function fetchResourceAccessExplanation( export function fetchResourceAccessExplanation(
settings: ApiSettings, settings: ApiSettings,
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null } options: ResourceAccessExplanationOptions
): Promise<ResourceAccessExplanationResponse> { ): Promise<ResourceAccessExplanationResponse> {
return apiFetch(settings, apiPath("/api/v1/admin/access/resource-explanation", { return fetchCoreResourceAccessExplanation<UserAdminItem, AccessDecisionProvenanceItem>(settings, options);
user_id: options.userId,
resource_type: options.resourceType,
resource_id: options.resourceId,
action: options.action,
tenant_id: options.tenantId
}));
} }
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> { export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {

View File

@@ -12,7 +12,11 @@ import type {
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { fetchShellAuth } 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 { PageScrollViewport } from "@govoplan/core-webui";
import {
TreeSubnav,
type TreeSubnavNode
} from "@govoplan/core-webui";
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui"; import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
import SystemUsersPanel from "./SystemUsersPanel"; import SystemUsersPanel from "./SystemUsersPanel";
import TenantSettingsPanel from "./TenantSettingsPanel"; import TenantSettingsPanel from "./TenantSettingsPanel";
@@ -25,10 +29,27 @@ import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPan
import ApiKeysPanel from "./ApiKeysPanel"; import ApiKeysPanel from "./ApiKeysPanel";
import FileConnectorsPanel from "./FileConnectorsPanel"; import FileConnectorsPanel from "./FileConnectorsPanel";
import MailProfilesPanel from "./MailProfilesPanel"; import MailProfilesPanel from "./MailProfilesPanel";
import { usePlatformUiCapabilities, usePlatformUiCapability } from "@govoplan/core-webui"; import CredentialEnvelopesPanel from "./CredentialEnvelopesPanel";
import {
isViewSurfaceVisible,
useEffectiveView,
usePlatformUiCapabilities,
usePlatformUiCapability,
useViewSurfaces
} from "@govoplan/core-webui";
type AdminSection = string; type AdminSection = string;
type OrderedAdminNavItem = { id: AdminSection; label: string; order: number }; type OrderedAdminNavItem = {
id: AdminSection;
label: string;
order: number;
moduleId?: string;
kind?: "management" | "settings";
};
type AdminNavGroup = {
title: string;
items: OrderedAdminNavItem[];
};
const handledAdminSectionIds = new Set<string>([ const handledAdminSectionIds = new Set<string>([
"overview", "overview",
@@ -43,6 +64,7 @@ const handledAdminSectionIds = new Set<string>([
"system-users", "system-users",
"system-file-connectors", "system-file-connectors",
"system-mail-servers", "system-mail-servers",
"system-credentials",
"tenant-settings", "tenant-settings",
"tenant-roles", "tenant-roles",
"tenant-function-role-mappings", "tenant-function-role-mappings",
@@ -50,13 +72,58 @@ const handledAdminSectionIds = new Set<string>([
"tenant-users", "tenant-users",
"tenant-file-connectors", "tenant-file-connectors",
"tenant-mail-servers", "tenant-mail-servers",
"tenant-credentials",
"tenant-api-keys", "tenant-api-keys",
"tenant-group-file-connectors", "tenant-group-file-connectors",
"tenant-group-mail-servers", "tenant-group-mail-servers",
"tenant-group-credentials",
"tenant-user-file-connectors", "tenant-user-file-connectors",
"tenant-user-mail-servers" "tenant-user-mail-servers",
"tenant-user-credentials"
]); ]);
const builtInAdminSurfaceIds: Record<string, string> = {
"system-tenants": "access.admin.system-tenants",
"system-roles": "access.admin.system-roles",
"system-users": "access.admin.system-users",
"system-credentials": "access.admin.system-credentials",
"tenant-roles": "access.admin.tenant-roles",
"tenant-function-role-mappings": "access.admin.tenant-function-mappings",
"tenant-groups": "access.admin.tenant-groups",
"tenant-users": "access.admin.tenant-users",
"tenant-credentials": "access.admin.tenant-credentials",
"tenant-api-keys": "access.admin.tenant-api-keys",
"tenant-settings": "access.admin.tenant-settings",
"tenant-group-credentials": "access.admin.group-credentials",
"tenant-user-credentials": "access.admin.user-credentials",
"system-mail-servers": "mail.admin.system-servers",
"tenant-mail-servers": "mail.admin.tenant-servers",
"tenant-group-mail-servers": "mail.admin.group-servers",
"tenant-user-mail-servers": "mail.admin.user-servers",
"tenant-group-file-connectors": "files.admin.group-connectors",
"tenant-user-file-connectors": "files.admin.user-connectors"
};
const builtInAdminSectionMetadata: Record<
string,
Pick<OrderedAdminNavItem, "moduleId" | "kind">
> = {
"system-settings": { moduleId: "admin", kind: "settings" },
"system-file-connectors": { moduleId: "files", kind: "settings" },
"system-mail-servers": { moduleId: "mail", kind: "settings" },
"system-credentials": { moduleId: "access", kind: "settings" },
"tenant-settings": { moduleId: "access", kind: "settings" },
"tenant-file-connectors": { moduleId: "files", kind: "settings" },
"tenant-mail-servers": { moduleId: "mail", kind: "settings" },
"tenant-credentials": { moduleId: "access", kind: "settings" },
"tenant-group-file-connectors": { moduleId: "files", kind: "settings" },
"tenant-group-mail-servers": { moduleId: "mail", kind: "settings" },
"tenant-group-credentials": { moduleId: "access", kind: "settings" },
"tenant-user-file-connectors": { moduleId: "files", kind: "settings" },
"tenant-user-mail-servers": { moduleId: "mail", kind: "settings" },
"tenant-user-credentials": { moduleId: "access", kind: "settings" }
};
export default function AdminPage({ export default function AdminPage({
settings, settings,
auth, auth,
@@ -70,14 +137,23 @@ export default function AdminPage({
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors"); const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker"); const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections"); const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
const effectiveView = useEffectiveView();
const viewSurfaces = useViewSurfaces();
const mailProfilesAvailable = Boolean(mailProfilesUi); const mailProfilesAvailable = Boolean(mailProfilesUi);
const fileConnectorsAvailable = Boolean(fileConnectorsUi); const fileConnectorsAvailable = Boolean(fileConnectorsUi);
const contributedSections = useMemo( const contributedSections = useMemo(
() => () =>
adminSectionCapabilities adminSectionCapabilities
.flatMap((capability) => capability.sections) .flatMap((capability) => capability.sections)
.filter((section) =>
isViewSurfaceVisible(
effectiveView,
section.surfaceId,
viewSurfaces
)
)
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)), .sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
[adminSectionCapabilities] [adminSectionCapabilities, effectiveView, viewSurfaces]
); );
const contributionById = useMemo(() => { const contributionById = useMemo(() => {
const mapped = new Map<string, AdminSectionContribution>(); const mapped = new Map<string, AdminSectionContribution>();
@@ -95,6 +171,9 @@ export default function AdminPage({
if (hasScope(auth, "system:settings:read")) { if (hasScope(auth, "system:settings:read")) {
if (mailProfilesAvailable) sections.add("system-mail-servers"); if (mailProfilesAvailable) sections.add("system-mail-servers");
} }
if (hasAnyScope(auth, ["system:settings:read", "access:system_credential:read"])) {
sections.add("system-credentials");
}
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants"); if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users"); if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles"); if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
@@ -113,8 +192,21 @@ export default function AdminPage({
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors"); if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
} }
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings"); if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
return sections; if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]); sections.add("tenant-credentials");
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials");
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials");
}
return new Set(
[...sections].filter((sectionId) =>
isViewSurfaceVisible(
effectiveView,
builtInAdminSurfaceIds[sectionId],
viewSurfaces
)
)
);
}, [auth, contributedSections, effectiveView, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, viewSurfaces]);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const requestedSection = searchParams.get("section") as AdminSection | null; const requestedSection = searchParams.get("section") as AdminSection | null;
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview"); const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
@@ -137,19 +229,20 @@ export default function AdminPage({
if (!hasAnyScope(auth, adminReadScopes)) { if (!hasAnyScope(auth, adminReadScopes)) {
return ( return (
<PageScrollViewport>
<div className="content-pad"> <div className="content-pad">
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5"> <Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p> <p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
</Card> </Card>
</div> </div>
</PageScrollViewport>
); );
} }
const adminSubnav: ModuleSubnavGroup<AdminSection>[] = [ const adminNavGroups: AdminNavGroup[] = [
{ {
title: "ADMINISTRATION", title: "ADMINISTRATION",
items: asSubnavItems( items: sortNavItems([
sortNavItems([
...contributedNavItems(contributedSections, available, "ROOT"), ...contributedNavItems(contributedSections, available, "ROOT"),
visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10), visibleNavItem(available, "system-modules", "i18n:govoplan-access.modules.04e9462c", 10),
visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20), visibleNavItem(available, "system-configuration-packages", "i18n:govoplan-access.packages.0a999012", 20),
@@ -157,12 +250,10 @@ export default function AdminPage({
visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40), visibleNavItem(available, "system-configuration-changes", "i18n:govoplan-access.changes.8aa57de6", 40),
...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "ADMINISTRATION", handledAdminSectionIds)
]) ])
)
}, },
{ {
title: "GLOBAL", title: "GLOBAL",
items: asSubnavItems( items: sortNavItems([
sortNavItems([
visibleNavItem(available, "system-tenants", "i18n:govoplan-access.tenants.1f7ae776", 10), visibleNavItem(available, "system-tenants", "i18n:govoplan-access.tenants.1f7ae776", 10),
visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20), visibleNavItem(available, "system-roles", "i18n:govoplan-access.system_roles.a9461aa6", 20),
visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30), visibleNavItem(available, "system-role-templates", "i18n:govoplan-access.tenant_role_templates", 30),
@@ -170,60 +261,70 @@ export default function AdminPage({
visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50), visibleNavItem(available, "system-users", "i18n:govoplan-access.users.57f2b181", 50),
visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60), visibleNavItem(available, "system-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 60),
visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70), visibleNavItem(available, "system-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 70),
visibleNavItem(available, "system-credentials", "i18n:govoplan-core.credentials.dd097a22", 80),
...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds), ...contributedNavItems(contributedSections, available, "GLOBAL", handledAdminSectionIds),
...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "SYSTEM", handledAdminSectionIds)
]) ])
)
}, },
{ {
title: "TENANT", title: "TENANT",
items: asSubnavItems( items: sortNavItems([
sortNavItems([
visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10), visibleNavItem(available, "tenant-roles", "i18n:govoplan-access.roles.47dcc27d", 10),
visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20), visibleNavItem(available, "tenant-function-role-mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3", 20),
visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30), visibleNavItem(available, "tenant-groups", "i18n:govoplan-access.groups.ae9629f4", 30),
visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40), visibleNavItem(available, "tenant-users", "i18n:govoplan-access.users.57f2b181", 40),
visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50), visibleNavItem(available, "tenant-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 50),
visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60), visibleNavItem(available, "tenant-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 60),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 70), visibleNavItem(available, "tenant-credentials", "i18n:govoplan-core.credentials.dd097a22", 70),
visibleNavItem(available, "tenant-api-keys", "i18n:govoplan-access.api_keys.94fcf3c2", 80),
visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90), visibleNavItem(available, "tenant-settings", "i18n:govoplan-access.general.9239ee2c", 90),
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
]) ])
)
}, },
{ {
title: "GROUP", title: "GROUP",
items: asSubnavItems( items: sortNavItems([
sortNavItems([
visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10), visibleNavItem(available, "tenant-group-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20), visibleNavItem(available, "tenant-group-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-group-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
]) ])
)
}, },
{ {
title: "USER", title: "USER",
items: asSubnavItems( items: sortNavItems([
sortNavItems([
visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10), visibleNavItem(available, "tenant-user-file-connectors", "i18n:govoplan-access.file_connections.1e362326", 10),
visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20), visibleNavItem(available, "tenant-user-mail-servers", "i18n:govoplan-access.mail_servers.d627326a", 20),
visibleNavItem(available, "tenant-user-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds) ...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
]) ])
)
} }
].filter((group) => group.items.length > 0); ].filter((group) => group.items.length > 0);
const adminTree = adminNavigationTree(adminNavGroups);
const contributedSection = contributionById.get(active); const contributedSection = contributionById.get(active);
const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection }; const contributionContext = { settings, auth, onAuthChange, refreshAuth, availableSections: available, selectSection };
return ( return (
<div className="workspace module-workspace"> <div className="workspace module-workspace">
<ModuleSubnav active={active} groups={adminSubnav} onSelect={selectSection} /> <TreeSubnav
active={active}
nodes={adminTree}
onSelect={selectSection}
ariaLabel="i18n:govoplan-access.admin.4e7afebc"
/>
<section className="workspace-content"> <section className="workspace-content">
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
{contributedSection && contributedSection.render(contributionContext)} {contributedSection && contributedSection.render(contributionContext)}
{!contributedSection && active === "system-mail-servers" && ( {!contributedSection && active === "system-mail-servers" && (
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} /> <MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
)} )}
{!contributedSection && active === "system-credentials" && (
<CredentialEnvelopesPanel
settings={settings}
scopeType="system"
canWrite={hasAnyScope(auth, ["system:settings:write", "access:system_credential:write"])}
/>
)}
{!contributedSection && active === "system-tenants" && ( {!contributedSection && active === "system-tenants" && (
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} /> <TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
)} )}
@@ -245,8 +346,11 @@ export default function AdminPage({
{!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-function-role-mappings" && organizationFunctionPicker && <ExternalFunctionRoleMappingsPanel settings={settings} auth={auth} functionPicker={organizationFunctionPicker} canWrite={hasAnyScope(auth, ["admin:roles:write", "access:function:write", "access:role:assign"])} onAuthRefresh={refreshAuth} />}
{!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />} {!contributedSection && active === "tenant-api-keys" && <ApiKeysPanel settings={settings} auth={auth} canCreate={hasScope(auth, "admin:api_keys:create")} canRevoke={hasScope(auth, "admin:api_keys:revoke")} />}
{!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />} {!contributedSection && active === "tenant-mail-servers" && <MailProfilesPanel settings={settings} scopeType="tenant" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasScope(auth, "admin:policies:write")} />}
{!contributedSection && active === "tenant-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="tenant" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />} {!contributedSection && active === "tenant-user-mail-servers" && <MailProfilesPanel settings={settings} scopeType="user" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
{!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />} {!contributedSection && active === "tenant-group-mail-servers" && <MailProfilesPanel settings={settings} scopeType="group" canWriteProfiles={hasScope(auth, "mail_servers:write")} canManageCredentials={hasScope(auth, "mail_servers:manage_credentials")} canWritePolicy={hasAnyScope(auth, ["admin:policies:write", "mail_servers:write"])} />}
{!contributedSection && active === "tenant-user-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-group-credentials" && <CredentialEnvelopesPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["admin:settings:write", "access:credential:write"])} />}
{!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />} {!contributedSection && active === "tenant-user-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="user" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
{!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />} {!contributedSection && active === "tenant-group-file-connectors" && <FileConnectorsPanel settings={settings} scopeType="group" canWrite={hasAnyScope(auth, ["files:file:admin", "admin:settings:write"])} />}
{!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />} {!contributedSection && active === "tenant-settings" && <TenantSettingsPanel settings={settings} canWrite={hasScope(auth, "admin:settings:write")} onAuthRefresh={refreshAuth} />}
@@ -270,17 +374,74 @@ function contributedNavItems(
): OrderedAdminNavItem[] { ): OrderedAdminNavItem[] {
return sections return sections
.filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id) && !excludedIds.has(section.id)) .filter((section) => (section.group ?? "SYSTEM") === group && available.has(section.id) && !excludedIds.has(section.id))
.map((section) => ({ id: section.id, label: section.label, order: section.order ?? 100 })); .map((section) => ({
id: section.id,
label: section.label,
order: section.order ?? 100,
moduleId: section.moduleId,
kind: section.kind
}));
} }
function visibleNavItem(available: ReadonlySet<string>, id: AdminSection, label: string, order: number): OrderedAdminNavItem | null { function visibleNavItem(available: ReadonlySet<string>, id: AdminSection, label: string, order: number): OrderedAdminNavItem | null {
return available.has(id) ? { id, label, order } : null; return available.has(id)
? { id, label, order, ...builtInAdminSectionMetadata[id] }
: null;
} }
function sortNavItems(items: Array<OrderedAdminNavItem | null>): OrderedAdminNavItem[] { function sortNavItems(items: Array<OrderedAdminNavItem | null>): OrderedAdminNavItem[] {
return items.filter((item): item is OrderedAdminNavItem => item !== null).sort((left, right) => left.order - right.order); return items.filter((item): item is OrderedAdminNavItem => item !== null).sort((left, right) => left.order - right.order);
} }
function asSubnavItems(items: OrderedAdminNavItem[]) { function adminNavigationTree(
return items.map(({ id, label }) => ({ id, label })); groups: AdminNavGroup[]
): TreeSubnavNode<AdminSection>[] {
return groups.map((group) => {
const managementItems = group.items.filter(
(item) => item.kind !== "settings"
);
const settingsItems = group.items.filter(
(item) => item.kind === "settings"
);
const children: TreeSubnavNode<AdminSection>[] = managementItems.map(
({ id, label }) => ({ id, label })
);
if (settingsItems.length > 0) {
const byModule = new Map<string, OrderedAdminNavItem[]>();
for (const item of settingsItems) {
const moduleId = item.moduleId ?? "platform";
byModule.set(moduleId, [...(byModule.get(moduleId) ?? []), item]);
}
children.push({
branchId: `admin-${group.title.toLowerCase()}-settings`,
label: "i18n:govoplan-core.settings.c7f73bb5",
defaultExpanded: false,
children: [...byModule.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([moduleId, items]) => ({
branchId: `admin-${group.title.toLowerCase()}-settings-${moduleId}`,
label: moduleLabel(moduleId),
defaultExpanded: items.some(
(item) => item.id === "system-settings"
),
children: items.map(({ id, label }) => ({ id, label }))
}))
});
}
return {
branchId: `admin-${group.title.toLowerCase()}`,
label: group.title,
defaultExpanded: group.title === "ADMINISTRATION",
children
};
});
}
function moduleLabel(moduleId: string): string {
if (moduleId === "platform") return "Platform";
return moduleId
.split(/[-_]/)
.filter(Boolean)
.map((part) => part[0].toUpperCase() + part.slice(1))
.join(" ");
} }

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

@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from "react";
import {
AdminPageLayout,
CredentialEnvelopeManager,
adminErrorMessage,
useDeltaWatermarks,
type ApiSettings,
type CredentialEnvelopeTargetOption,
type MailProfileScope
} from "@govoplan/core-webui";
import {
fetchGroupsDelta,
fetchUsersDelta,
type GroupSummary,
type UserAdminItem
} from "../../api/admin";
import { loadDeltaRows } from "./utils/deltaRows";
type ScopeType = Extract<MailProfileScope, "system" | "tenant" | "user" | "group">;
export default function CredentialEnvelopesPanel({
settings,
scopeType,
canWrite
}: {
settings: ApiSettings;
scopeType: ScopeType;
canWrite: boolean;
}) {
const [targets, setTargets] = useState<CredentialEnvelopeTargetOption[]>([]);
const [loadingTargets, setLoadingTargets] = useState(
scopeType === "user" || scopeType === "group"
);
const [targetError, setTargetError] = useState("");
const usersRef = useRef<UserAdminItem[]>([]);
const groupsRef = useRef<GroupSummary[]>([]);
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } =
useDeltaWatermarks();
useEffect(() => {
usersRef.current = [];
groupsRef.current = [];
resetDeltaWatermark();
void loadTargets();
}, [
resetDeltaWatermark,
scopeType,
settings.accessToken,
settings.apiBaseUrl,
settings.apiKey
]);
async function loadTargets() {
if (scopeType !== "user" && scopeType !== "group") {
setTargets([]);
setLoadingTargets(false);
setTargetError("");
return;
}
setLoadingTargets(true);
setTargetError("");
try {
if (scopeType === "user") {
const users = await loadDeltaRows(
usersRef.current,
"access:credential-users",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchUsersDelta(settings, { since }),
(response) => response.users,
(user) => user.id,
"access_user",
(left, right) => left.email.localeCompare(right.email)
);
usersRef.current = users;
setTargets(
users.map((user) => ({
id: user.id,
label: user.display_name || user.email,
secondary: user.display_name ? user.email : null
}))
);
} else {
const groups = await loadDeltaRows(
groupsRef.current,
"access:credential-groups",
getDeltaWatermark,
setDeltaWatermark,
(since) => fetchGroupsDelta(settings, { since }),
(response) => response.groups,
(group) => group.id,
"access_group",
(left, right) =>
left.name.localeCompare(right.name) || left.slug.localeCompare(right.slug)
);
groupsRef.current = groups;
setTargets(
groups.map((group) => ({
id: group.id,
label: group.name,
secondary: group.slug
}))
);
}
} catch (err) {
setTargets([]);
setTargetError(adminErrorMessage(err));
} finally {
setLoadingTargets(false);
}
}
return (
<AdminPageLayout
title={scopeTitle(scopeType)}
description={scopeDescription(scopeType)}
loading={loadingTargets}
error={targetError}
>
<CredentialEnvelopeManager
settings={settings}
scopeType={scopeType}
targetOptions={targets}
targetLabel={scopeType === "group" ? "Group" : "User"}
title="Reusable credentials"
canWrite={canWrite}
/>
</AdminPageLayout>
);
}
function scopeTitle(scopeType: ScopeType): string {
if (scopeType === "system") return "System credentials";
if (scopeType === "tenant") return "Tenant credentials";
if (scopeType === "group") return "Group credentials";
return "User credentials";
}
function scopeDescription(scopeType: ScopeType): string {
if (scopeType === "system") {
return "Instance credentials that can be inherited by tenants and limited to selected modules or servers.";
}
if (scopeType === "tenant") {
return "Tenant credentials shared by Mail, Files, Calendar, Addresses, and other permitted modules.";
}
return `Reusable credentials owned by the selected ${scopeType}.`;
}

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: "",
@@ -149,16 +149,13 @@ export default function SystemUsersPanel({
setSavedDraftKey(draftKey(emptyDraft)); setSavedDraftKey(draftKey(emptyDraft));
} }
function membership(tenantId: string) { function setMembershipSelection(tenantIds: string[]) {
return draft.memberships.find((item) => item.tenant_id === tenantId);
}
function setMembership(tenantId: string, enabled: boolean) {
setDraft((current) => ({ setDraft((current) => ({
...current, ...current,
memberships: enabled ? memberships: tenantIds.map((tenantId) =>
[...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] : current.memberships.find((item) => item.tenant_id === tenantId) ??
current.memberships.filter((item) => item.tenant_id !== tenantId) { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }
)
})); }));
} }
@@ -215,11 +212,11 @@ export default function SystemUsersPanel({
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) }, { id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> }, { id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) }, { id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions"> { id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} /> { id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} /> { id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), onClick: () => openEdit(row) },
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} /> { id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), onClick: () => setDeactivating(row) }
</div> }], ]} /> }],
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]); [canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
return ( return (
@@ -253,7 +250,7 @@ export default function SystemUsersPanel({
</div> </div>
<div className="admin-assignment-grid"> <div className="admin-assignment-grid">
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div> <div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div> <div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
</div> </div>
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>} {editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p> <p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>

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

@@ -108,6 +108,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}", "i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}",
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for", "i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny", "i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
"i18n:govoplan-access.available.7c62a142": "Available",
"i18n:govoplan-access.file_connections.1e362326": "File connections", "i18n:govoplan-access.file_connections.1e362326": "File connections",
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable", "i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
"i18n:govoplan-access.files.6ce6c512": "Files", "i18n:govoplan-access.files.6ce6c512": "Files",
@@ -119,6 +120,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.", "i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.", "i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings", "i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
"i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Map accepted function facts to tenant roles.",
"i18n:govoplan-access.general.9239ee2c": "General", "i18n:govoplan-access.general.9239ee2c": "General",
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details", "i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.", "i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
@@ -456,6 +458,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren", "i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren",
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for", "i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny", "i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
"i18n:govoplan-access.available.7c62a142": "Verfuegbar",
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen", "i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar", "i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
"i18n:govoplan-access.files.6ce6c512": "Dateien", "i18n:govoplan-access.files.6ce6c512": "Dateien",
@@ -467,6 +470,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.", "i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.", "i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen", "i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
"i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Akzeptierte Funktionsfakten Mandantenrollen zuordnen.",
"i18n:govoplan-access.general.9239ee2c": "Allgemein", "i18n:govoplan-access.general.9239ee2c": "Allgemein",
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details", "i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.", "i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",

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

View File

@@ -10,6 +10,23 @@ const translations = {
de: generatedTranslations.de de: generatedTranslations.de
}; };
const accessAdminSurfaces = [
{ id: "access.admin.system-tenants", moduleId: "access", kind: "section" as const, label: "System tenants", order: 10 },
{ id: "access.admin.system-roles", moduleId: "access", kind: "section" as const, label: "System roles", order: 20 },
{ id: "access.admin.system-users", moduleId: "access", kind: "section" as const, label: "System users", order: 50 },
{ id: "access.admin.system-credentials", moduleId: "access", kind: "section" as const, label: "System credentials", order: 80 },
{ id: "access.admin.tenant-roles", moduleId: "access", kind: "section" as const, label: "Tenant roles", order: 10 },
{ id: "access.admin.tenant-function-mappings", moduleId: "access", kind: "section" as const, label: "Function mappings", order: 20 },
{ id: "access.admin.tenant-groups", moduleId: "access", kind: "section" as const, label: "Tenant groups", order: 30 },
{ id: "access.admin.tenant-users", moduleId: "access", kind: "section" as const, label: "Tenant users", order: 40 },
{ id: "access.admin.tenant-credentials", moduleId: "access", kind: "section" as const, label: "Tenant credentials", order: 70 },
{ id: "access.admin.tenant-api-keys", moduleId: "access", kind: "section" as const, label: "Tenant API keys", order: 80 },
{ id: "access.admin.tenant-settings", moduleId: "access", kind: "section" as const, label: "Tenant settings", order: 90 },
{ id: "access.admin.group-credentials", moduleId: "access", kind: "section" as const, label: "Group credentials", order: 30 },
{ id: "access.admin.user-credentials", moduleId: "access", kind: "section" as const, label: "User credentials", order: 30 },
{ id: "access.settings.credentials", moduleId: "access", kind: "section" as const, label: "Personal credentials", order: 30 }
];
function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) { function renderAdminRoute({ settings, auth, onAuthChange }: PlatformRouteContext) {
if (!onAuthChange) { if (!onAuthChange) {
throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f"); throw new Error("i18n:govoplan-access.the_access_admin_route_requires_the_platform_aut.0173a45f");
@@ -22,6 +39,7 @@ export const accessModule: PlatformWebModule = {
label: "i18n:govoplan-access.access.2f81a22d", label: "i18n:govoplan-access.access.2f81a22d",
version: "1.0.0", version: "1.0.0",
translations, translations,
viewSurfaces: accessAdminSurfaces,
navItems: [ navItems: [
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }], { to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
@@ -30,4 +48,4 @@ export const accessModule: PlatformWebModule = {
}; };
export default accessModule; export default accessModule;