Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8ea347654 | |||
| a758c8f2da | |||
| 6295cfa840 | |||
| b6c2c89adf | |||
| 6b0dd8beab | |||
| 984a015704 | |||
| 3df4fc5bff | |||
| 1b57df7753 | |||
| bf1ecc54b3 | |||
| fdfcfbb440 | |||
| f1d64d247e | |||
| 198803d5b9 | |||
| 79781662e8 | |||
| 4075ac4d73 | |||
| 21f77ffc50 | |||
| d0ef0531c0 | |||
| c2917459a4 | |||
| fb1b573855 | |||
| 8c74e360d2 | |||
| e91935c03a | |||
| 01d082e552 | |||
| 90d8f65835 | |||
| 1c8e18e109 | |||
| ab07075a67 |
34
README.md
34
README.md
@@ -18,7 +18,13 @@ module directly. Access-side admin service helpers remain
|
|||||||
here for users, groups, roles, system accounts, sessions, API keys, tenant
|
here for users, groups, roles, system accounts, sessions, API keys, tenant
|
||||||
access enforcement, admin/audit lookup capabilities, tenant owner
|
access enforcement, admin/audit lookup capabilities, tenant owner
|
||||||
provisioning, and governance-template materialization into access-owned groups
|
provisioning, and governance-template materialization into access-owned groups
|
||||||
and roles. Governance-template metadata CRUD lives in `govoplan-admin`. The
|
and roles. The module also enforces narrowly declared managed
|
||||||
|
`RoleTemplate.default_authenticated` baselines. Their explicit permissions are
|
||||||
|
derived from the active manifest set for every authenticated tenant member,
|
||||||
|
without writing assignments during an authorization read. The optional role
|
||||||
|
row is a non-assignable administration projection, not the source of the
|
||||||
|
automatic grant. Domain permissions and resource policy remain separate checks.
|
||||||
|
Governance-template metadata CRUD lives in `govoplan-admin`. The
|
||||||
transitional administration WebUI route shell and
|
transitional administration WebUI route shell and
|
||||||
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
|
access-owned panels live under `webui/src` as `@govoplan/access-webui`. Generic
|
||||||
system administration panels are contributed by `@govoplan/admin-webui` through
|
system administration panels are contributed by `@govoplan/admin-webui` through
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/access-webui",
|
"name": "@govoplan/access-webui",
|
||||||
"version": "0.1.8",
|
"version": "0.1.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.8",
|
"@govoplan/core-webui": "^0.1.11",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-access"
|
name = "govoplan-access"
|
||||||
version = "0.1.8"
|
version = "0.1.11"
|
||||||
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
description = "GovOPlaN access platform module with identity, auth, RBAC, and scope primitives."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.8",
|
"govoplan-core>=0.1.11",
|
||||||
|
"redis>=5,<6",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""GovOPlaN access platform module."""
|
"""GovOPlaN access platform module."""
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.11"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from govoplan_access.backend.permissions.catalog import (
|
|||||||
validate_tenant_permissions,
|
validate_tenant_permissions,
|
||||||
role_templates_for_level,
|
role_templates_for_level,
|
||||||
)
|
)
|
||||||
from govoplan_core.tenancy.service import tenant_counts # re-exported compatibility helper
|
from govoplan_core.tenancy.service import tenant_counts # noqa: F401 - re-exported compatibility helper
|
||||||
|
|
||||||
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
|
_TEMP_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-_!@#"
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict
|
|||||||
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
|
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
|
||||||
role = query.one_or_none()
|
role = query.one_or_none()
|
||||||
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
|
||||||
is_assignable = True
|
is_assignable = not template.default_authenticated
|
||||||
if role is None:
|
if role is None:
|
||||||
role = Role(
|
role = Role(
|
||||||
tenant_id=tenant.id if tenant is not None else None,
|
tenant_id=tenant.id if tenant is not None else None,
|
||||||
@@ -200,7 +200,24 @@ def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -
|
|||||||
|
|
||||||
|
|
||||||
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
|
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
|
||||||
ids = sorted(set(role_ids))
|
default_slugs = {
|
||||||
|
template.slug
|
||||||
|
for template in role_templates_for_level("tenant")
|
||||||
|
if template.default_authenticated
|
||||||
|
}
|
||||||
|
default_roles = (
|
||||||
|
session.query(Role)
|
||||||
|
.filter(
|
||||||
|
Role.tenant_id == user.tenant_id,
|
||||||
|
Role.slug.in_(default_slugs),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
if default_slugs
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
default_role_ids = {role.id for role in default_roles}
|
||||||
|
requested_ids = set(role_ids) - default_role_ids
|
||||||
|
ids = sorted(requested_ids)
|
||||||
roles = (
|
roles = (
|
||||||
session.query(Role)
|
session.query(Role)
|
||||||
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
|
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.admin.service import (
|
from govoplan_access.backend.admin.service import (
|
||||||
@@ -21,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,
|
||||||
)
|
)
|
||||||
@@ -41,10 +43,15 @@ from govoplan_access.backend.db.models import (
|
|||||||
Tenant,
|
Tenant,
|
||||||
User,
|
User,
|
||||||
UserGroupMembership,
|
UserGroupMembership,
|
||||||
|
UserRoleAssignment,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||||
from govoplan_core.core.organizations import OrganizationDirectory
|
from govoplan_core.core.organizations import OrganizationDirectory
|
||||||
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:
|
||||||
@@ -82,13 +89,147 @@ def _resolve_tenant(
|
|||||||
return tenant
|
return tenant
|
||||||
|
|
||||||
|
|
||||||
def _role_summary(session: Session, role: Role) -> RoleSummary:
|
def _tenant_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, tuple[int, int]]:
|
||||||
|
if not role_ids:
|
||||||
|
return {}
|
||||||
|
user_counts = {
|
||||||
|
role_id: count
|
||||||
|
for role_id, count in session.query(UserRoleAssignment.role_id, func.count(UserRoleAssignment.id))
|
||||||
|
.filter(UserRoleAssignment.role_id.in_(role_ids))
|
||||||
|
.group_by(UserRoleAssignment.role_id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
group_counts = {
|
||||||
|
role_id: count
|
||||||
|
for role_id, count in session.query(GroupRoleAssignment.role_id, func.count(GroupRoleAssignment.id))
|
||||||
|
.filter(GroupRoleAssignment.role_id.in_(role_ids))
|
||||||
|
.group_by(GroupRoleAssignment.role_id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
return {role_id: (int(user_counts.get(role_id, 0)), int(group_counts.get(role_id, 0))) for role_id in role_ids}
|
||||||
|
|
||||||
|
|
||||||
|
def _system_role_assignment_counts(session: Session, role_ids: list[str]) -> dict[str, int]:
|
||||||
|
if not role_ids:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
role_id: int(count)
|
||||||
|
for role_id, count in session.query(SystemRoleAssignment.role_id, func.count(SystemRoleAssignment.id))
|
||||||
|
.filter(SystemRoleAssignment.role_id.in_(role_ids))
|
||||||
|
.group_by(SystemRoleAssignment.role_id)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _accounts_by_id(session: Session, account_ids: list[str]) -> dict[str, Account]:
|
||||||
|
if not account_ids:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
account.id: account
|
||||||
|
for account in session.query(Account).filter(Account.id.in_(sorted(set(account_ids)))).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _accounts_by_user_id(session: Session, user_ids: list[str]) -> dict[str, Account]:
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
user.id: account
|
||||||
|
for user, account in session.query(User, Account)
|
||||||
|
.join(Account, Account.id == User.account_id)
|
||||||
|
.filter(User.id.in_(sorted(set(user_ids))))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _group_member_ids_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[str]]:
|
||||||
|
grouped: dict[str, list[str]] = defaultdict(list)
|
||||||
|
if not group_ids:
|
||||||
|
return {}
|
||||||
|
for group_id, user_id in (
|
||||||
|
session.query(UserGroupMembership.group_id, UserGroupMembership.user_id)
|
||||||
|
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.group_id.in_(sorted(set(group_ids))))
|
||||||
|
.order_by(UserGroupMembership.group_id.asc(), UserGroupMembership.user_id.asc())
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
grouped[group_id].append(user_id)
|
||||||
|
return dict(grouped)
|
||||||
|
|
||||||
|
|
||||||
|
def _roles_by_group_id(session: Session, *, tenant_id: str, group_ids: list[str]) -> dict[str, list[Role]]:
|
||||||
|
grouped: dict[str, list[Role]] = defaultdict(list)
|
||||||
|
if not group_ids:
|
||||||
|
return {}
|
||||||
|
for group_id, role in (
|
||||||
|
session.query(GroupRoleAssignment.group_id, Role)
|
||||||
|
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||||
|
.filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id.in_(sorted(set(group_ids))), Role.tenant_id == tenant_id)
|
||||||
|
.order_by(GroupRoleAssignment.group_id.asc(), Role.name.asc())
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
grouped[group_id].append(role)
|
||||||
|
return dict(grouped)
|
||||||
|
|
||||||
|
|
||||||
|
def _groups_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Group]]:
|
||||||
|
grouped: dict[str, list[Group]] = defaultdict(list)
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
for user_id, group in (
|
||||||
|
session.query(UserGroupMembership.user_id, Group)
|
||||||
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||||
|
.filter(
|
||||||
|
UserGroupMembership.tenant_id == tenant_id,
|
||||||
|
UserGroupMembership.user_id.in_(sorted(set(user_ids))),
|
||||||
|
Group.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
grouped[user_id].append(group)
|
||||||
|
return dict(grouped)
|
||||||
|
|
||||||
|
|
||||||
|
def _roles_by_user_id(session: Session, *, tenant_id: str, user_ids: list[str]) -> dict[str, list[Role]]:
|
||||||
|
grouped: dict[str, list[Role]] = defaultdict(list)
|
||||||
|
if not user_ids:
|
||||||
|
return {}
|
||||||
|
for user_id, role in (
|
||||||
|
session.query(UserRoleAssignment.user_id, Role)
|
||||||
|
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||||
|
.filter(
|
||||||
|
UserRoleAssignment.tenant_id == tenant_id,
|
||||||
|
UserRoleAssignment.user_id.in_(sorted(set(user_ids))),
|
||||||
|
Role.tenant_id == tenant_id,
|
||||||
|
)
|
||||||
|
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
grouped[user_id].append(role)
|
||||||
|
return dict(grouped)
|
||||||
|
|
||||||
|
|
||||||
|
def _role_summary(
|
||||||
|
session: Session,
|
||||||
|
role: Role,
|
||||||
|
*,
|
||||||
|
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||||
|
system_role_assignment_counts: dict[str, int] | None = None,
|
||||||
|
) -> RoleSummary:
|
||||||
group_count = 0
|
group_count = 0
|
||||||
if role.tenant_id is None:
|
if role.tenant_id is None:
|
||||||
user_count = session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
|
user_count = (
|
||||||
|
system_role_assignment_counts.get(role.id, 0)
|
||||||
|
if system_role_assignment_counts is not None
|
||||||
|
else session.query(SystemRoleAssignment).filter(SystemRoleAssignment.role_id == role.id).count()
|
||||||
|
)
|
||||||
permission_level = "system"
|
permission_level = "system"
|
||||||
else:
|
else:
|
||||||
user_count, group_count = role_assignment_counts(session, role.id)
|
user_count, group_count = (
|
||||||
|
tenant_role_assignment_counts.get(role.id, (0, 0))
|
||||||
|
if tenant_role_assignment_counts is not None
|
||||||
|
else role_assignment_counts(session, role.id)
|
||||||
|
)
|
||||||
permission_level = "tenant"
|
permission_level = "tenant"
|
||||||
permissions = list(role.permissions or [])
|
permissions = list(role.permissions or [])
|
||||||
return RoleSummary(
|
return RoleSummary(
|
||||||
@@ -108,19 +249,35 @@ def _role_summary(session: Session, role: Role) -> RoleSummary:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _group_summary(session: Session, group: Group, *, include_members: bool = True) -> GroupSummary:
|
def _group_summary(
|
||||||
member_ids = [
|
session: Session,
|
||||||
row[0]
|
group: Group,
|
||||||
for row in session.query(UserGroupMembership.user_id)
|
*,
|
||||||
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
|
include_members: bool = True,
|
||||||
.all()
|
member_ids_by_group: dict[str, list[str]] | None = None,
|
||||||
]
|
roles_by_group: dict[str, list[Role]] | None = None,
|
||||||
|
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||||
|
) -> GroupSummary:
|
||||||
|
member_ids = (
|
||||||
|
member_ids_by_group.get(group.id, [])
|
||||||
|
if member_ids_by_group is not None
|
||||||
|
else [
|
||||||
|
row[0]
|
||||||
|
for row in session.query(UserGroupMembership.user_id)
|
||||||
|
.filter(UserGroupMembership.tenant_id == group.tenant_id, UserGroupMembership.group_id == group.id)
|
||||||
|
.all()
|
||||||
|
]
|
||||||
|
)
|
||||||
roles = (
|
roles = (
|
||||||
session.query(Role)
|
roles_by_group.get(group.id, [])
|
||||||
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
if roles_by_group is not None
|
||||||
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
|
else (
|
||||||
.order_by(Role.name.asc())
|
session.query(Role)
|
||||||
.all()
|
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||||
|
.filter(GroupRoleAssignment.tenant_id == group.tenant_id, GroupRoleAssignment.group_id == group.id)
|
||||||
|
.order_by(Role.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return GroupSummary(
|
return GroupSummary(
|
||||||
id=group.id,
|
id=group.id,
|
||||||
@@ -130,7 +287,7 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
|
|||||||
is_active=group.is_active,
|
is_active=group.is_active,
|
||||||
member_count=len(member_ids),
|
member_count=len(member_ids),
|
||||||
member_ids=member_ids if include_members else [],
|
member_ids=member_ids if include_members else [],
|
||||||
roles=[_role_summary(session, role) for role in roles],
|
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
|
||||||
created_at=group.created_at,
|
created_at=group.created_at,
|
||||||
updated_at=group.updated_at,
|
updated_at=group.updated_at,
|
||||||
system_template_id=group.system_template_id,
|
system_template_id=group.system_template_id,
|
||||||
@@ -145,12 +302,18 @@ def _user_item(
|
|||||||
owner_ids: set[str] | None = None,
|
owner_ids: set[str] | None = None,
|
||||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||||
organization_directory: OrganizationDirectory | None = None,
|
organization_directory: OrganizationDirectory | None = None,
|
||||||
|
accounts_by_id: dict[str, Account] | None = None,
|
||||||
|
groups_by_user: dict[str, list[Group]] | None = None,
|
||||||
|
roles_by_user: dict[str, list[Role]] | None = None,
|
||||||
|
group_member_ids_by_group: dict[str, list[str]] | None = None,
|
||||||
|
group_roles_by_group: dict[str, list[Role]] | None = None,
|
||||||
|
tenant_role_assignment_counts: dict[str, tuple[int, int]] | None = None,
|
||||||
) -> UserAdminItem:
|
) -> UserAdminItem:
|
||||||
account = session.get(Account, user.account_id)
|
account = accounts_by_id.get(user.account_id) if accounts_by_id is not None else session.get(Account, user.account_id)
|
||||||
if account is None:
|
if account is None:
|
||||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
|
||||||
groups = collect_user_groups(session, user)
|
groups = groups_by_user.get(user.id, []) if groups_by_user is not None else collect_user_groups(session, user)
|
||||||
roles = collect_direct_user_roles(session, user)
|
roles = roles_by_user.get(user.id, []) if roles_by_user is not None else collect_direct_user_roles(session, user)
|
||||||
external_roles = (
|
external_roles = (
|
||||||
collect_external_function_roles(
|
collect_external_function_roles(
|
||||||
session,
|
session,
|
||||||
@@ -176,8 +339,18 @@ def _user_item(
|
|||||||
account_is_active=account.is_active,
|
account_is_active=account.is_active,
|
||||||
password_reset_required=account.password_reset_required,
|
password_reset_required=account.password_reset_required,
|
||||||
last_login_at=account.last_login_at,
|
last_login_at=account.last_login_at,
|
||||||
groups=[_group_summary(session, group, include_members=False) for group in groups],
|
groups=[
|
||||||
roles=[_role_summary(session, role) for role in roles],
|
_group_summary(
|
||||||
|
session,
|
||||||
|
group,
|
||||||
|
include_members=False,
|
||||||
|
member_ids_by_group=group_member_ids_by_group,
|
||||||
|
roles_by_group=group_roles_by_group,
|
||||||
|
tenant_role_assignment_counts=tenant_role_assignment_counts,
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
],
|
||||||
|
roles=[_role_summary(session, role, tenant_role_assignment_counts=tenant_role_assignment_counts) for role in roles],
|
||||||
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
|
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
|
||||||
function_delegation_ids=collect_function_delegation_ids(session, user),
|
function_delegation_ids=collect_function_delegation_ids(session, user),
|
||||||
effective_scopes=expand_scopes(effective_scopes),
|
effective_scopes=expand_scopes(effective_scopes),
|
||||||
@@ -188,47 +361,255 @@ 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 _api_key_item(session: Session, item: ApiKey) -> ApiKeyAdminItem:
|
def _system_account_items(
|
||||||
user = session.get(User, item.user_id)
|
session: Session,
|
||||||
account = session.get(Account, user.account_id) if user else None
|
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:
|
||||||
|
if accounts_by_user_id is not None:
|
||||||
|
account = accounts_by_user_id.get(item.user_id)
|
||||||
|
else:
|
||||||
|
user = session.get(User, item.user_id)
|
||||||
|
account = session.get(Account, user.account_id) if user else None
|
||||||
return ApiKeyAdminItem(
|
return ApiKeyAdminItem(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
user_id=item.user_id,
|
user_id=item.user_id,
|
||||||
|
|||||||
@@ -3,41 +3,10 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||||
|
from govoplan_core.privacy.schemas import PrivacyRetentionPolicyItem, PrivacyRetentionPolicyPatchItem
|
||||||
RETENTION_DAY_KEYS = (
|
|
||||||
"raw_campaign_json_retention_days",
|
|
||||||
"generated_eml_retention_days",
|
|
||||||
"stored_report_detail_retention_days",
|
|
||||||
"mock_mailbox_retention_days",
|
|
||||||
"audit_detail_retention_days",
|
|
||||||
)
|
|
||||||
RETENTION_POLICY_FIELD_KEYS = (
|
|
||||||
"store_raw_campaign_json",
|
|
||||||
*RETENTION_DAY_KEYS,
|
|
||||||
"audit_detail_level",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def default_allow_lower_level_limits() -> dict[str, bool]:
|
|
||||||
return {key: True for key in RETENTION_POLICY_FIELD_KEYS}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool) -> dict[str, bool] | None:
|
|
||||||
if value in (None, ""):
|
|
||||||
return default_allow_lower_level_limits() if fill_defaults else None
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError("allow_lower_level_limits must be an object")
|
|
||||||
normalized = default_allow_lower_level_limits() if fill_defaults else {}
|
|
||||||
for key, allowed in value.items():
|
|
||||||
clean_key = str(key)
|
|
||||||
if clean_key not in RETENTION_POLICY_FIELD_KEYS:
|
|
||||||
raise ValueError(f"Unknown retention policy field: {clean_key}")
|
|
||||||
normalized[clean_key] = bool(allowed)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PermissionItem(BaseModel):
|
class PermissionItem(BaseModel):
|
||||||
@@ -98,6 +67,13 @@ class TenantOwnerCandidateListResponse(BaseModel):
|
|||||||
accounts: list[TenantOwnerCandidate]
|
accounts: list[TenantOwnerCandidate]
|
||||||
|
|
||||||
|
|
||||||
|
class PagedListResponse(BaseModel):
|
||||||
|
total: int = 0
|
||||||
|
page: int = 1
|
||||||
|
page_size: int = 500
|
||||||
|
pages: int = 1
|
||||||
|
|
||||||
|
|
||||||
class TenantCreateRequest(BaseModel):
|
class TenantCreateRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -179,7 +155,7 @@ class IdentityAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class IdentityListResponse(BaseModel):
|
class IdentityListResponse(PagedListResponse):
|
||||||
identities: list[IdentityAdminItem]
|
identities: list[IdentityAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -217,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]
|
||||||
|
|
||||||
|
|
||||||
@@ -256,7 +232,7 @@ class FunctionAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionListResponse(BaseModel):
|
class FunctionListResponse(PagedListResponse):
|
||||||
functions: list[FunctionAdminItem]
|
functions: list[FunctionAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +247,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
class ExternalFunctionRoleMappingListResponse(PagedListResponse):
|
||||||
mappings: list[ExternalFunctionRoleMappingItem]
|
mappings: list[ExternalFunctionRoleMappingItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -342,7 +318,7 @@ class FunctionAssignmentAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionAssignmentListResponse(BaseModel):
|
class FunctionAssignmentListResponse(PagedListResponse):
|
||||||
assignments: list[FunctionAssignmentAdminItem]
|
assignments: list[FunctionAssignmentAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -392,7 +368,7 @@ class FunctionDelegationAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionDelegationListResponse(BaseModel):
|
class FunctionDelegationListResponse(PagedListResponse):
|
||||||
delegations: list[FunctionDelegationAdminItem]
|
delegations: list[FunctionDelegationAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -529,7 +505,7 @@ class ResourceAccessExplanationResponse(BaseModel):
|
|||||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(BaseModel):
|
class UserListResponse(PagedListResponse):
|
||||||
users: list[UserAdminItem]
|
users: list[UserAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -567,7 +543,7 @@ class UserUpdateRequest(BaseModel):
|
|||||||
role_ids: list[str] | None = None
|
role_ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class GroupListResponse(BaseModel):
|
class GroupListResponse(PagedListResponse):
|
||||||
groups: list[GroupSummary]
|
groups: list[GroupSummary]
|
||||||
|
|
||||||
|
|
||||||
@@ -599,7 +575,7 @@ class GroupUpdateRequest(BaseModel):
|
|||||||
role_ids: list[str] | None = None
|
role_ids: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class RoleListResponse(BaseModel):
|
class RoleListResponse(PagedListResponse):
|
||||||
roles: list[RoleSummary]
|
roles: list[RoleSummary]
|
||||||
|
|
||||||
|
|
||||||
@@ -638,7 +614,7 @@ class SystemAccountItem(BaseModel):
|
|||||||
last_login_at: datetime | None = None
|
last_login_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class SystemAccountListResponse(BaseModel):
|
class SystemAccountListResponse(PagedListResponse):
|
||||||
accounts: list[SystemAccountItem]
|
accounts: list[SystemAccountItem]
|
||||||
roles: list[RoleSummary]
|
roles: list[RoleSummary]
|
||||||
|
|
||||||
@@ -704,42 +680,6 @@ class PolicySourceStepItem(BaseModel):
|
|||||||
policy: dict[str, Any] = Field(default_factory=dict)
|
policy: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class PrivacyRetentionPolicyItem(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
store_raw_campaign_json: bool = True
|
|
||||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
audit_detail_level: Literal["full", "redacted", "minimal"] = "full"
|
|
||||||
allow_lower_level_limits: dict[str, bool] = Field(default_factory=default_allow_lower_level_limits)
|
|
||||||
|
|
||||||
@field_validator("allow_lower_level_limits", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
|
||||||
return normalize_allow_lower_level_limits(value, fill_defaults=True)
|
|
||||||
|
|
||||||
|
|
||||||
class PrivacyRetentionPolicyPatchItem(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
store_raw_campaign_json: bool | None = None
|
|
||||||
raw_campaign_json_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
generated_eml_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
stored_report_detail_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
mock_mailbox_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
audit_detail_retention_days: int | None = Field(default=None, ge=0)
|
|
||||||
audit_detail_level: Literal["full", "redacted", "minimal"] | None = None
|
|
||||||
allow_lower_level_limits: dict[str, bool] | None = None
|
|
||||||
|
|
||||||
@field_validator("allow_lower_level_limits", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _normalize_allow_lower_level_limits(cls, value: Any) -> Any:
|
|
||||||
return normalize_allow_lower_level_limits(value, fill_defaults=False)
|
|
||||||
|
|
||||||
|
|
||||||
class PrivacyRetentionPolicyScopeRequest(BaseModel):
|
class PrivacyRetentionPolicyScopeRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -774,7 +714,7 @@ class ConfigurationSafetyFieldItem(BaseModel):
|
|||||||
storage: str
|
storage: str
|
||||||
ui_managed: bool
|
ui_managed: bool
|
||||||
risk: Literal["low", "medium", "high", "destructive"]
|
risk: Literal["low", "medium", "high", "destructive"]
|
||||||
secret_handling: Literal["none", "reference_only", "env_only"] = "none"
|
secret_handling: Literal["none", "reference_only", "env_only"] = "none" # noqa: S105 - policy vocabulary.
|
||||||
required_scopes: list[str] = Field(default_factory=list)
|
required_scopes: list[str] = Field(default_factory=list)
|
||||||
dry_run_required: bool = False
|
dry_run_required: bool = False
|
||||||
validation_required: bool = True
|
validation_required: bool = True
|
||||||
@@ -912,7 +852,7 @@ class ApiKeyAdminItem(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyListResponse(BaseModel):
|
class ApiKeyListResponse(PagedListResponse):
|
||||||
api_keys: list[ApiKeyAdminItem]
|
api_keys: list[ApiKeyAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -936,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"
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.api.v1.schemas import (
|
from govoplan_core.api.v1.schemas import (
|
||||||
|
AuthGroupsResponse,
|
||||||
|
AuthProfileResponse,
|
||||||
|
AuthRolesResponse,
|
||||||
|
AuthShellResponse,
|
||||||
|
AuthSessionResponse,
|
||||||
|
AuthSessionUserInfo,
|
||||||
GroupInfo,
|
GroupInfo,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@@ -22,9 +31,9 @@ from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityD
|
|||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||||
from govoplan_core.admin.settings import get_system_settings
|
from govoplan_core.admin.settings import get_system_settings
|
||||||
from govoplan_core.audit.logging import audit_from_principal
|
from govoplan_core.audit.logging import audit_event
|
||||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||||
from govoplan_access.backend.db.models import Account, Tenant, User
|
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Group, Role, Tenant, User
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_core.i18n import (
|
from govoplan_core.i18n import (
|
||||||
i18n_settings,
|
i18n_settings,
|
||||||
@@ -36,24 +45,44 @@ from govoplan_core.i18n import (
|
|||||||
tenant_enabled_language_codes,
|
tenant_enabled_language_codes,
|
||||||
user_enabled_language_codes,
|
user_enabled_language_codes,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant
|
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes, normalize_email, scopes_grant
|
||||||
from govoplan_core.security.time import utc_now
|
from govoplan_core.security.time import utc_now
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||||
from govoplan_access.backend.security.passwords import verify_password
|
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||||
|
from govoplan_access.backend.security.passwords import DUMMY_PASSWORD_HASH, verify_password
|
||||||
|
from govoplan_access.backend.security.login_throttle import (
|
||||||
|
LoginThrottle,
|
||||||
|
LoginThrottleDecision,
|
||||||
|
build_login_throttle,
|
||||||
|
)
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
|
authenticate_session_token,
|
||||||
|
collect_user_authorization_context,
|
||||||
collect_system_roles,
|
collect_system_roles,
|
||||||
collect_tenant_memberships,
|
collect_tenant_memberships,
|
||||||
collect_user_groups,
|
collect_user_groups,
|
||||||
collect_user_roles,
|
collect_user_roles,
|
||||||
collect_user_scopes,
|
collect_user_scopes,
|
||||||
create_auth_session,
|
create_auth_session,
|
||||||
|
verify_auth_session_csrf,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthContext:
|
||||||
|
account: Account
|
||||||
|
user: User
|
||||||
|
tenant: Tenant
|
||||||
|
auth_method: str
|
||||||
|
source: str
|
||||||
|
auth_session: AuthSession | None = None
|
||||||
|
api_key: ApiKey | None = None
|
||||||
|
|
||||||
|
|
||||||
def _cookie_samesite() -> str:
|
def _cookie_samesite() -> str:
|
||||||
value = settings.auth_cookie_samesite.lower().strip()
|
value = settings.auth_cookie_samesite.lower().strip()
|
||||||
if value not in {"lax", "strict", "none"}:
|
if value not in {"lax", "strict", "none"}:
|
||||||
@@ -125,6 +154,18 @@ def _user_info(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_user_info(user: User, account: Account) -> AuthSessionUserInfo:
|
||||||
|
return AuthSessionUserInfo(
|
||||||
|
id=user.id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
display_name=account.display_name or user.display_name,
|
||||||
|
tenant_display_name=user.display_name,
|
||||||
|
is_tenant_admin=user.is_tenant_admin,
|
||||||
|
password_reset_required=account.password_reset_required,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]:
|
def _roles_info(roles, *, level: str = "tenant") -> list[RoleInfo]:
|
||||||
return [
|
return [
|
||||||
RoleInfo(
|
RoleInfo(
|
||||||
@@ -142,10 +183,20 @@ def _groups_info(groups) -> list[GroupInfo]:
|
|||||||
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
|
return [GroupInfo(id=group.id, slug=group.slug, name=group.name) for group in groups]
|
||||||
|
|
||||||
|
|
||||||
def _tenant_memberships(session: Session, account: Account) -> list[TenantMembershipInfo]:
|
def _tenant_memberships(
|
||||||
|
session: Session,
|
||||||
|
account: Account,
|
||||||
|
*,
|
||||||
|
active_user_id: str | None = None,
|
||||||
|
active_tenant_roles: list[Role] | None = None,
|
||||||
|
) -> list[TenantMembershipInfo]:
|
||||||
memberships: list[TenantMembershipInfo] = []
|
memberships: list[TenantMembershipInfo] = []
|
||||||
for user, tenant in collect_tenant_memberships(session, account):
|
for user, tenant in collect_tenant_memberships(session, account):
|
||||||
roles = collect_user_roles(session, user)
|
roles = (
|
||||||
|
active_tenant_roles
|
||||||
|
if active_tenant_roles is not None and user.id == active_user_id
|
||||||
|
else collect_user_roles(session, user)
|
||||||
|
)
|
||||||
memberships.append(
|
memberships.append(
|
||||||
TenantMembershipInfo(
|
TenantMembershipInfo(
|
||||||
id=tenant.id,
|
id=tenant.id,
|
||||||
@@ -159,6 +210,20 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember
|
|||||||
return memberships
|
return memberships
|
||||||
|
|
||||||
|
|
||||||
|
def _tenant_membership_summaries(session: Session, account: Account) -> list[TenantMembershipInfo]:
|
||||||
|
return [
|
||||||
|
TenantMembershipInfo(
|
||||||
|
id=tenant.id,
|
||||||
|
slug=tenant.slug,
|
||||||
|
name=tenant.name,
|
||||||
|
is_active=tenant.is_active and user.is_active,
|
||||||
|
default_locale=tenant.default_locale,
|
||||||
|
roles=[],
|
||||||
|
)
|
||||||
|
for user, tenant in collect_tenant_memberships(session, account)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]:
|
def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]:
|
||||||
system_settings = get_system_settings(session)
|
system_settings = get_system_settings(session)
|
||||||
system_payload = system_i18n_payload(system_settings)
|
system_payload = system_i18n_payload(system_settings)
|
||||||
@@ -194,7 +259,9 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
|
|||||||
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
.filter(Account.normalized_email == normalize_email(payload.email), Account.is_active.is_(True))
|
||||||
.one_or_none()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
if account is None or not verify_password(payload.password, account.password_hash):
|
password_hash = account.password_hash if account is not None and account.password_hash else DUMMY_PASSWORD_HASH
|
||||||
|
password_matches = verify_password(payload.password, password_hash)
|
||||||
|
if account is None or not account.password_hash or not password_matches:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
@@ -210,10 +277,321 @@ def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Accoun
|
|||||||
query = query.filter(Tenant.slug == payload.tenant_slug)
|
query = query.filter(Tenant.slug == payload.tenant_slug)
|
||||||
row = query.order_by(Tenant.name.asc()).first()
|
row = query.order_by(Tenant.name.asc()).first()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="No active tenant membership")
|
# Keep every authentication failure generic so callers cannot infer
|
||||||
|
# whether an account exists but lacks an active tenant membership.
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid login")
|
||||||
return account, row[0], row[1]
|
return account, row[0], row[1]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _configured_login_throttle() -> LoginThrottle | None:
|
||||||
|
if not settings.auth_login_throttle_enabled:
|
||||||
|
return None
|
||||||
|
return build_login_throttle(
|
||||||
|
redis_url=settings.redis_url,
|
||||||
|
identity_limit=settings.auth_login_throttle_identity_limit,
|
||||||
|
client_limit=settings.auth_login_throttle_client_limit,
|
||||||
|
window_seconds=settings.auth_login_throttle_window_seconds,
|
||||||
|
redis_retry_seconds=settings.auth_login_throttle_redis_retry_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_login_throttled(decision: LoginThrottleDecision) -> None:
|
||||||
|
retry_after = max(1, decision.retry_after_seconds)
|
||||||
|
# The detail deliberately matches every credential failure. Status and
|
||||||
|
# Retry-After communicate endpoint throttling without disclosing whether
|
||||||
|
# the supplied identity exists or has an active membership.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Invalid login",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_throttled_login_user(
|
||||||
|
session: Session,
|
||||||
|
payload: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> tuple[Account, User, Tenant]:
|
||||||
|
throttle = _configured_login_throttle()
|
||||||
|
if throttle is None:
|
||||||
|
return _resolve_login_user(session, payload)
|
||||||
|
|
||||||
|
normalized_email = normalize_email(payload.email)
|
||||||
|
client_address = request.client.host if request.client else None
|
||||||
|
context = {
|
||||||
|
"normalized_email": normalized_email,
|
||||||
|
"tenant_slug": payload.tenant_slug,
|
||||||
|
"client_address": client_address,
|
||||||
|
}
|
||||||
|
decision = throttle.check(**context)
|
||||||
|
if not decision.allowed:
|
||||||
|
_raise_login_throttled(decision)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = _resolve_login_user(session, payload)
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code == status.HTTP_401_UNAUTHORIZED:
|
||||||
|
decision = throttle.record_failure(**context)
|
||||||
|
if not decision.allowed:
|
||||||
|
_raise_login_throttled(decision)
|
||||||
|
raise
|
||||||
|
|
||||||
|
throttle.record_success(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
tenant_slug=payload.tenant_slug,
|
||||||
|
)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_auth_token(request: Request) -> tuple[str | None, str]:
|
||||||
|
x_api_key = request.headers.get("x-api-key")
|
||||||
|
if x_api_key:
|
||||||
|
return x_api_key.strip(), "api_key"
|
||||||
|
authorization = request.headers.get("authorization")
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
return authorization[7:].strip(), "bearer"
|
||||||
|
cookie_token = request.cookies.get(settings.auth_session_cookie_name)
|
||||||
|
if cookie_token:
|
||||||
|
return cookie_token.strip(), "cookie"
|
||||||
|
return None, "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _active_context_or_401(
|
||||||
|
*,
|
||||||
|
user: User | None,
|
||||||
|
account: Account | None,
|
||||||
|
tenant: Tenant | None,
|
||||||
|
expected_tenant_id: str,
|
||||||
|
) -> tuple[Account, User, Tenant]:
|
||||||
|
if (
|
||||||
|
not user or not account or not tenant
|
||||||
|
or not user.is_active or not account.is_active or not tenant.is_active
|
||||||
|
or user.account_id != account.id
|
||||||
|
or user.tenant_id != expected_tenant_id
|
||||||
|
or tenant.id != expected_tenant_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent session")
|
||||||
|
return account, user, tenant
|
||||||
|
|
||||||
|
|
||||||
|
def _session_response(
|
||||||
|
*,
|
||||||
|
account: Account,
|
||||||
|
user: User,
|
||||||
|
tenant: Tenant,
|
||||||
|
auth_method: str,
|
||||||
|
auth_session: AuthSession | None = None,
|
||||||
|
api_key: ApiKey | None = None,
|
||||||
|
) -> AuthSessionResponse:
|
||||||
|
active_tenant = _tenant_info(tenant)
|
||||||
|
return AuthSessionResponse(
|
||||||
|
authenticated=True,
|
||||||
|
auth_method=auth_method, # type: ignore[arg-type]
|
||||||
|
user=_session_user_info(user, account),
|
||||||
|
tenant=active_tenant,
|
||||||
|
active_tenant=active_tenant,
|
||||||
|
session_id=auth_session.id if auth_session else None,
|
||||||
|
api_key_id=api_key.id if api_key else None,
|
||||||
|
expires_at=auth_session.expires_at if auth_session else api_key.expires_at if api_key else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_auth_context(request: Request, session: Session) -> AuthContext:
|
||||||
|
token, source = _extract_auth_token(request)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||||
|
|
||||||
|
api_key = authenticate_api_key(session, token) if source != "cookie" else None
|
||||||
|
if api_key is not None:
|
||||||
|
user = session.get(User, api_key.user_id)
|
||||||
|
account = session.get(Account, user.account_id) if user else None
|
||||||
|
tenant = session.get(Tenant, api_key.tenant_id)
|
||||||
|
account, user, tenant = _active_context_or_401(
|
||||||
|
user=user,
|
||||||
|
account=account,
|
||||||
|
tenant=tenant,
|
||||||
|
expected_tenant_id=api_key.tenant_id,
|
||||||
|
)
|
||||||
|
return AuthContext(
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
auth_method="api_key",
|
||||||
|
source=source,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_session = authenticate_session_token(session, token)
|
||||||
|
if auth_session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||||
|
user = session.get(User, auth_session.user_id)
|
||||||
|
account = session.get(Account, auth_session.account_id)
|
||||||
|
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||||
|
account, user, tenant = _active_context_or_401(
|
||||||
|
user=user,
|
||||||
|
account=account,
|
||||||
|
tenant=tenant,
|
||||||
|
expected_tenant_id=auth_session.tenant_id,
|
||||||
|
)
|
||||||
|
return AuthContext(
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
auth_method="session",
|
||||||
|
source=source,
|
||||||
|
auth_session=auth_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shell_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
account: Account,
|
||||||
|
user: User,
|
||||||
|
tenant: Tenant,
|
||||||
|
scopes: list[str],
|
||||||
|
auth_method: str,
|
||||||
|
auth_session: AuthSession | None = None,
|
||||||
|
api_key: ApiKey | None = None,
|
||||||
|
) -> AuthShellResponse:
|
||||||
|
active_tenant = _tenant_info(tenant)
|
||||||
|
memberships = (
|
||||||
|
_tenant_membership_summaries(session, account)
|
||||||
|
if auth_session is not None
|
||||||
|
else [
|
||||||
|
TenantMembershipInfo(
|
||||||
|
id=tenant.id,
|
||||||
|
slug=tenant.slug,
|
||||||
|
name=tenant.name,
|
||||||
|
is_active=tenant.is_active and user.is_active,
|
||||||
|
default_locale=tenant.default_locale,
|
||||||
|
roles=[],
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return AuthShellResponse(
|
||||||
|
user=_user_info(user, account),
|
||||||
|
tenant=active_tenant,
|
||||||
|
active_tenant=active_tenant,
|
||||||
|
tenants=memberships,
|
||||||
|
scopes=scopes,
|
||||||
|
principal=PrincipalContextInfo(
|
||||||
|
account_id=account.id,
|
||||||
|
membership_id=user.id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
scopes=scopes,
|
||||||
|
auth_method=auth_method, # type: ignore[arg-type]
|
||||||
|
api_key_id=api_key.id if api_key else None,
|
||||||
|
session_id=auth_session.id if auth_session else None,
|
||||||
|
email=account.email,
|
||||||
|
display_name=account.display_name or user.display_name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_lightweight_session(request: Request, session: Session) -> AuthSessionResponse:
|
||||||
|
context = _resolve_auth_context(request, session)
|
||||||
|
return _session_response(
|
||||||
|
account=context.account,
|
||||||
|
user=context.user,
|
||||||
|
tenant=context.tenant,
|
||||||
|
auth_method=context.auth_method,
|
||||||
|
auth_session=context.auth_session,
|
||||||
|
api_key=context.api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_shell_auth(request: Request, session: Session) -> AuthShellResponse:
|
||||||
|
context = _resolve_auth_context(request, session)
|
||||||
|
if context.api_key is not None:
|
||||||
|
user_scopes = collect_user_scopes(session, context.user, include_system=False)
|
||||||
|
scopes = intersect_api_key_scopes(user_scopes, context.api_key.scopes or [])
|
||||||
|
return _shell_response(
|
||||||
|
session,
|
||||||
|
account=context.account,
|
||||||
|
user=context.user,
|
||||||
|
tenant=context.tenant,
|
||||||
|
scopes=scopes,
|
||||||
|
auth_method="api_key",
|
||||||
|
api_key=context.api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
scopes = collect_user_scopes(session, context.user, include_system=True)
|
||||||
|
return _shell_response(
|
||||||
|
session,
|
||||||
|
account=context.account,
|
||||||
|
user=context.user,
|
||||||
|
tenant=context.tenant,
|
||||||
|
scopes=scopes,
|
||||||
|
auth_method="session",
|
||||||
|
auth_session=context.auth_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_response(session: Session, context: AuthContext) -> AuthProfileResponse:
|
||||||
|
languages = _language_context(session, tenant=context.tenant, user=context.user)
|
||||||
|
tenant_enabled = list(languages["tenant_enabled_language_codes"])
|
||||||
|
user_enabled = list(languages["user_enabled_language_codes"])
|
||||||
|
preferred_language = str(languages["preferred_language"])
|
||||||
|
active_tenant = _tenant_info(context.tenant, enabled_language_codes=tenant_enabled)
|
||||||
|
return AuthProfileResponse(
|
||||||
|
user=_user_info(
|
||||||
|
context.user,
|
||||||
|
context.account,
|
||||||
|
preferred_language=preferred_language,
|
||||||
|
enabled_language_codes=user_enabled,
|
||||||
|
),
|
||||||
|
tenant=active_tenant,
|
||||||
|
active_tenant=active_tenant,
|
||||||
|
available_languages=languages["available_languages"],
|
||||||
|
enabled_language_codes=tenant_enabled,
|
||||||
|
default_language=preferred_language,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _roles_response(session: Session, context: AuthContext) -> AuthRolesResponse:
|
||||||
|
tenant_roles = collect_user_roles(session, context.user)
|
||||||
|
system_roles = collect_system_roles(session, context.account) if context.auth_session is not None else []
|
||||||
|
return AuthRolesResponse(roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"))
|
||||||
|
|
||||||
|
|
||||||
|
def _groups_response(session: Session, context: AuthContext) -> AuthGroupsResponse:
|
||||||
|
return AuthGroupsResponse(groups=_groups_info(collect_user_groups(session, context.user)))
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_profile_mutation_allowed(request: Request, context: AuthContext) -> None:
|
||||||
|
if context.auth_session is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
|
||||||
|
if context.source != "cookie":
|
||||||
|
return
|
||||||
|
header_token = request.headers.get("x-csrf-token")
|
||||||
|
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
|
||||||
|
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(context.auth_session, header_token):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
|
||||||
|
|
||||||
|
|
||||||
|
def _roles_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> tuple[list[Role], list[Role]]:
|
||||||
|
roles = (
|
||||||
|
session.query(Role)
|
||||||
|
.filter(Role.id.in_(sorted(principal.role_ids)))
|
||||||
|
.order_by(Role.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
tenant_roles = [role for role in roles if role.tenant_id == tenant_id]
|
||||||
|
system_roles = [role for role in roles if role.tenant_id is None and principal.auth_session is not None]
|
||||||
|
return tenant_roles, system_roles
|
||||||
|
|
||||||
|
|
||||||
|
def _groups_from_principal(session: Session, principal: ApiPrincipal, *, tenant_id: str) -> list[Group]:
|
||||||
|
return (
|
||||||
|
session.query(Group)
|
||||||
|
.filter(Group.tenant_id == tenant_id, Group.id.in_(sorted(principal.group_ids)))
|
||||||
|
.order_by(Group.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _me_response(
|
def _me_response(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -229,17 +607,42 @@ def _me_response(
|
|||||||
include_all_memberships: bool = True,
|
include_all_memberships: bool = True,
|
||||||
identity_directory: IdentityDirectory | None = None,
|
identity_directory: IdentityDirectory | None = None,
|
||||||
identity_id: str | None = None,
|
identity_id: str | None = None,
|
||||||
|
tenant_roles: list[Role] | None = None,
|
||||||
|
system_roles: list[Role] | None = None,
|
||||||
|
groups: list[Group] | None = None,
|
||||||
|
function_assignment_ids: tuple[str, ...] | None = None,
|
||||||
|
delegation_ids: tuple[str, ...] | None = None,
|
||||||
) -> MeResponse:
|
) -> MeResponse:
|
||||||
tenant_roles = collect_user_roles(session, user)
|
if tenant_roles is None:
|
||||||
system_roles = collect_system_roles(session, account) if include_system else []
|
tenant_roles = collect_user_roles(session, user)
|
||||||
groups = collect_user_groups(session, user)
|
if system_roles is None:
|
||||||
|
system_roles = collect_system_roles(session, account) if include_system else []
|
||||||
|
elif not include_system:
|
||||||
|
system_roles = []
|
||||||
|
if groups is None:
|
||||||
|
groups = collect_user_groups(session, user)
|
||||||
scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system)
|
scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system)
|
||||||
|
resolved_function_assignment_ids = (
|
||||||
|
function_assignment_ids
|
||||||
|
if function_assignment_ids is not None
|
||||||
|
else tuple(collect_function_assignment_ids(session, user))
|
||||||
|
)
|
||||||
|
resolved_delegation_ids = (
|
||||||
|
delegation_ids
|
||||||
|
if delegation_ids is not None
|
||||||
|
else tuple(collect_function_delegation_ids(session, user))
|
||||||
|
)
|
||||||
languages = _language_context(session, tenant=tenant, user=user)
|
languages = _language_context(session, tenant=tenant, user=user)
|
||||||
tenant_enabled = list(languages["tenant_enabled_language_codes"])
|
tenant_enabled = list(languages["tenant_enabled_language_codes"])
|
||||||
user_enabled = list(languages["user_enabled_language_codes"])
|
user_enabled = list(languages["user_enabled_language_codes"])
|
||||||
preferred_language = str(languages["preferred_language"])
|
preferred_language = str(languages["preferred_language"])
|
||||||
active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled)
|
active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled)
|
||||||
memberships = _tenant_memberships(session, account) if include_all_memberships else [
|
memberships = _tenant_memberships(
|
||||||
|
session,
|
||||||
|
account,
|
||||||
|
active_user_id=user.id,
|
||||||
|
active_tenant_roles=tenant_roles,
|
||||||
|
) if include_all_memberships else [
|
||||||
TenantMembershipInfo(
|
TenantMembershipInfo(
|
||||||
id=tenant.id,
|
id=tenant.id,
|
||||||
slug=tenant.slug,
|
slug=tenant.slug,
|
||||||
@@ -266,8 +669,8 @@ def _me_response(
|
|||||||
scopes=frozenset(scopes),
|
scopes=frozenset(scopes),
|
||||||
group_ids=frozenset(group.id for group in groups),
|
group_ids=frozenset(group.id for group in groups),
|
||||||
role_ids=frozenset(role.id for role in tenant_roles + system_roles),
|
role_ids=frozenset(role.id for role in tenant_roles + system_roles),
|
||||||
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)),
|
function_assignment_ids=frozenset(resolved_function_assignment_ids),
|
||||||
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
|
delegation_ids=frozenset(resolved_delegation_ids),
|
||||||
auth_method=auth_method,
|
auth_method=auth_method,
|
||||||
api_key_id=api_key_id,
|
api_key_id=api_key_id,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
@@ -284,11 +687,20 @@ def _me_response(
|
|||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
|
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
|
||||||
account, user, tenant = _resolve_login_user(session, payload)
|
account, user, tenant = _resolve_throttled_login_user(session, payload, request)
|
||||||
identity_directory = _identity_directory_from_request(request)
|
identity_directory = _identity_directory_from_request(request)
|
||||||
me_payload = _me_response(session, account=account, user=user, tenant=tenant, identity_directory=identity_directory)
|
authorization_context = collect_user_authorization_context(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
account=account,
|
||||||
|
include_system=True,
|
||||||
|
)
|
||||||
|
tenant_roles = authorization_context.tenant_roles
|
||||||
|
system_roles = authorization_context.system_roles
|
||||||
|
groups = authorization_context.groups
|
||||||
|
effective_scopes = authorization_context.scopes
|
||||||
maintenance_mode = saved_maintenance_mode(session)
|
maintenance_mode = saved_maintenance_mode(session)
|
||||||
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE):
|
if maintenance_mode.enabled and not scopes_grant(effective_scopes, MAINTENANCE_ACCESS_SCOPE):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail=maintenance_response_detail(maintenance_mode),
|
detail=maintenance_response_detail(maintenance_mode),
|
||||||
@@ -312,19 +724,66 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
|
|||||||
account=account,
|
account=account,
|
||||||
user=user,
|
user=user,
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
effective_scopes=me_payload.scopes,
|
effective_scopes=effective_scopes,
|
||||||
auth_method="session",
|
auth_method="session",
|
||||||
session_id=created.model.id,
|
session_id=created.model.id,
|
||||||
identity_directory=identity_directory,
|
identity_directory=identity_directory,
|
||||||
|
tenant_roles=tenant_roles,
|
||||||
|
system_roles=system_roles,
|
||||||
|
groups=groups,
|
||||||
|
function_assignment_ids=authorization_context.function_assignment_ids,
|
||||||
|
delegation_ids=authorization_context.function_delegation_ids,
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", response_model=AuthSessionResponse)
|
||||||
|
def auth_session(request: Request, session: Session = Depends(get_session)):
|
||||||
|
return _resolve_lightweight_session(request, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/shell", response_model=AuthShellResponse)
|
||||||
|
def auth_shell(request: Request, session: Session = Depends(get_session)):
|
||||||
|
return _resolve_shell_auth(request, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile", response_model=AuthProfileResponse)
|
||||||
|
def auth_profile(request: Request, session: Session = Depends(get_session)):
|
||||||
|
return _profile_response(session, _resolve_auth_context(request, session))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roles", response_model=AuthRolesResponse)
|
||||||
|
def auth_roles(request: Request, session: Session = Depends(get_session)):
|
||||||
|
context = _resolve_auth_context(request, session)
|
||||||
|
authorization_context = collect_user_authorization_context(
|
||||||
|
session,
|
||||||
|
context.user,
|
||||||
|
account=context.account,
|
||||||
|
include_system=context.auth_session is not None,
|
||||||
|
)
|
||||||
|
return AuthRolesResponse(
|
||||||
|
roles=_roles_info(authorization_context.tenant_roles)
|
||||||
|
+ _roles_info(authorization_context.system_roles, level="system")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/groups", response_model=AuthGroupsResponse)
|
||||||
|
def auth_groups(request: Request, session: Session = Depends(get_session)):
|
||||||
|
return _groups_response(session, _resolve_auth_context(request, session))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=MeResponse)
|
@router.get("/me", response_model=MeResponse)
|
||||||
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
|
def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session)):
|
||||||
tenant = session.get(Tenant, principal.tenant_id)
|
tenant = session.get(Tenant, principal.tenant_id)
|
||||||
if tenant is None:
|
if tenant is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
|
||||||
|
tenant_roles: list[Role] | None = None
|
||||||
|
system_roles: list[Role] | None = None
|
||||||
|
groups: list[Group] | None = None
|
||||||
|
if principal.role_ids:
|
||||||
|
tenant_roles, system_roles = _roles_from_principal(session, principal, tenant_id=tenant.id)
|
||||||
|
if principal.group_ids:
|
||||||
|
groups = _groups_from_principal(session, principal, tenant_id=tenant.id)
|
||||||
return _me_response(
|
return _me_response(
|
||||||
session,
|
session,
|
||||||
account=principal.account,
|
account=principal.account,
|
||||||
@@ -338,33 +797,42 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
|
|||||||
include_system=principal.auth_session is not None,
|
include_system=principal.auth_session is not None,
|
||||||
include_all_memberships=principal.auth_session is not None,
|
include_all_memberships=principal.auth_session is not None,
|
||||||
identity_id=principal.principal.identity_id,
|
identity_id=principal.principal.identity_id,
|
||||||
|
tenant_roles=tenant_roles,
|
||||||
|
system_roles=system_roles,
|
||||||
|
groups=groups,
|
||||||
|
function_assignment_ids=tuple(principal.function_assignment_ids),
|
||||||
|
delegation_ids=tuple(principal.delegation_ids),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/profile", response_model=MeResponse)
|
@router.patch("/profile", response_model=AuthProfileResponse)
|
||||||
def update_profile(
|
def update_profile(
|
||||||
payload: ProfileUpdateRequest,
|
payload: ProfileUpdateRequest,
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
request: Request,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
if principal.auth_session is None:
|
context = _resolve_auth_context(request, session)
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot edit an interactive user profile")
|
_verify_profile_mutation_allowed(request, context)
|
||||||
|
|
||||||
if "display_name" in payload.model_fields_set:
|
if "display_name" in payload.model_fields_set:
|
||||||
principal.account.display_name = payload.display_name.strip() if payload.display_name else None
|
context.account.display_name = payload.display_name.strip() if payload.display_name else None
|
||||||
session.add(principal.account)
|
session.add(context.account)
|
||||||
if "tenant_display_name" in payload.model_fields_set:
|
if "tenant_display_name" in payload.model_fields_set:
|
||||||
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
|
context.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
|
||||||
session.add(principal.user)
|
session.add(context.user)
|
||||||
next_settings = dict(principal.user.settings or {})
|
next_settings = dict(context.user.settings or {})
|
||||||
settings_changed = False
|
settings_changed = False
|
||||||
if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set):
|
if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set):
|
||||||
tenant = session.get(Tenant, principal.tenant_id)
|
|
||||||
if tenant is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
|
|
||||||
system_settings = get_system_settings(session)
|
system_settings = get_system_settings(session)
|
||||||
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
|
system_enabled = system_enabled_language_codes(
|
||||||
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
|
system_settings.settings,
|
||||||
|
default_locale=system_settings.default_locale,
|
||||||
|
)
|
||||||
|
tenant_enabled = tenant_enabled_language_codes(
|
||||||
|
context.tenant.settings,
|
||||||
|
system_enabled,
|
||||||
|
default_locale=context.tenant.default_locale,
|
||||||
|
)
|
||||||
next_i18n = i18n_settings(next_settings)
|
next_i18n = i18n_settings(next_settings)
|
||||||
if "preferred_language" in payload.model_fields_set:
|
if "preferred_language" in payload.model_fields_set:
|
||||||
preferred = normalize_language_code(payload.preferred_language)
|
preferred = normalize_language_code(payload.preferred_language)
|
||||||
@@ -394,35 +862,23 @@ def update_profile(
|
|||||||
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
||||||
settings_changed = True
|
settings_changed = True
|
||||||
if settings_changed:
|
if settings_changed:
|
||||||
principal.user.settings = next_settings
|
context.user.settings = next_settings
|
||||||
session.add(principal.user)
|
session.add(context.user)
|
||||||
|
|
||||||
audit_from_principal(
|
audit_event(
|
||||||
session,
|
session,
|
||||||
principal,
|
tenant_id=context.tenant.id,
|
||||||
|
user_id=context.user.id,
|
||||||
action="profile.updated",
|
action="profile.updated",
|
||||||
object_type="account",
|
object_type="account",
|
||||||
object_id=principal.account.id,
|
object_id=context.account.id,
|
||||||
details={"fields": sorted(payload.model_fields_set)},
|
details={"fields": sorted(payload.model_fields_set)},
|
||||||
)
|
)
|
||||||
tenant = session.get(Tenant, principal.tenant_id)
|
|
||||||
if tenant is None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
|
|
||||||
session.commit()
|
session.commit()
|
||||||
return _me_response(
|
return _profile_response(session, context)
|
||||||
session,
|
|
||||||
account=principal.account,
|
|
||||||
user=principal.user,
|
|
||||||
tenant=tenant,
|
|
||||||
auth_method=principal.auth_method,
|
|
||||||
session_id=principal.session_id,
|
|
||||||
include_system=True,
|
|
||||||
include_all_memberships=True,
|
|
||||||
identity_id=principal.principal.identity_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/switch-tenant", response_model=MeResponse)
|
@router.post("/switch-tenant", response_model=AuthShellResponse)
|
||||||
def switch_tenant(
|
def switch_tenant(
|
||||||
payload: SwitchTenantRequest,
|
payload: SwitchTenantRequest,
|
||||||
principal: ApiPrincipal = Depends(get_api_principal),
|
principal: ApiPrincipal = Depends(get_api_principal),
|
||||||
@@ -441,14 +897,20 @@ def switch_tenant(
|
|||||||
if membership is None:
|
if membership is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
|
||||||
session.commit()
|
session.commit()
|
||||||
return _me_response(
|
authorization_context = collect_user_authorization_context(
|
||||||
|
session,
|
||||||
|
membership,
|
||||||
|
account=principal.account,
|
||||||
|
include_system=True,
|
||||||
|
)
|
||||||
|
return _shell_response(
|
||||||
session,
|
session,
|
||||||
account=principal.account,
|
account=principal.account,
|
||||||
user=membership,
|
user=membership,
|
||||||
tenant=tenant,
|
tenant=tenant,
|
||||||
|
scopes=authorization_context.scopes,
|
||||||
auth_method="session",
|
auth_method="session",
|
||||||
session_id=principal.session_id,
|
auth_session=principal.auth_session,
|
||||||
identity_id=principal.principal.identity_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
236
src/govoplan_access/backend/api/v1/service_accounts.py
Normal file
236
src/govoplan_access/backend/api/v1/service_accounts.py
Normal 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"]
|
||||||
File diff suppressed because it is too large
Load Diff
82
src/govoplan_access/backend/auth/principal_cache.py
Normal file
82
src/govoplan_access/backend/auth/principal_cache.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
102
src/govoplan_access/backend/people_search.py
Normal file
102
src/govoplan_access/backend/people_search.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, User
|
||||||
|
from govoplan_core.core.people import (
|
||||||
|
PeopleSearchError,
|
||||||
|
PeopleSearchGroup,
|
||||||
|
PersonSearchCandidate,
|
||||||
|
person_selection_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal_tenant_id(principal: object) -> str:
|
||||||
|
try:
|
||||||
|
tenant_id = getattr(principal, "tenant_id")
|
||||||
|
except (AttributeError, RuntimeError) as exc:
|
||||||
|
raise PeopleSearchError("People search requires an active tenant context.") from exc
|
||||||
|
normalized = str(tenant_id or "").strip()
|
||||||
|
if not normalized:
|
||||||
|
raise PeopleSearchError("People search requires an active tenant context.")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _like_pattern(query: str) -> str:
|
||||||
|
escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
return f"%{escaped}%"
|
||||||
|
|
||||||
|
|
||||||
|
class AccessPeopleSearchProvider:
|
||||||
|
"""Search active accounts through their active tenant membership.
|
||||||
|
|
||||||
|
The active principal's tenant is the only accepted visibility boundary;
|
||||||
|
global accounts and memberships of other tenants are never candidates.
|
||||||
|
Feature routers authorize the surrounding task before invoking this
|
||||||
|
capability.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def search_people(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> tuple[PeopleSearchGroup, ...]:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise PeopleSearchError("People search requires a database session.")
|
||||||
|
tenant_id = _principal_tenant_id(principal)
|
||||||
|
normalized_limit = max(1, min(int(limit), 100))
|
||||||
|
account_query = (
|
||||||
|
session.query(User, Account)
|
||||||
|
.join(Account, Account.id == User.account_id)
|
||||||
|
.filter(
|
||||||
|
User.tenant_id == tenant_id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
Account.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
normalized_query = str(query or "").strip()
|
||||||
|
if normalized_query:
|
||||||
|
pattern = _like_pattern(normalized_query)
|
||||||
|
account_query = account_query.filter(
|
||||||
|
or_(
|
||||||
|
func.lower(User.display_name).like(pattern, escape="\\"),
|
||||||
|
func.lower(User.email).like(pattern, escape="\\"),
|
||||||
|
func.lower(Account.display_name).like(pattern, escape="\\"),
|
||||||
|
func.lower(Account.email).like(pattern, escape="\\"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
account_query
|
||||||
|
.order_by(
|
||||||
|
func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(),
|
||||||
|
Account.id.asc(),
|
||||||
|
)
|
||||||
|
.limit(normalized_limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
candidates = tuple(
|
||||||
|
PersonSearchCandidate(
|
||||||
|
selection_key=person_selection_key("account", account.id),
|
||||||
|
kind="account",
|
||||||
|
reference_id=account.id,
|
||||||
|
display_name=user.display_name or account.display_name or user.email or account.email,
|
||||||
|
email=user.email or account.email,
|
||||||
|
source_module="access",
|
||||||
|
source_label="Accounts",
|
||||||
|
source_ref=f"access:account:{account.id}",
|
||||||
|
)
|
||||||
|
for user, account in rows
|
||||||
|
)
|
||||||
|
return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),)
|
||||||
|
|
||||||
|
|
||||||
|
def people_search_capability(_context: object) -> AccessPeopleSearchProvider:
|
||||||
|
return AccessPeopleSearchProvider()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AccessPeopleSearchProvider", "people_search_capability"]
|
||||||
@@ -3,71 +3,26 @@ from __future__ import annotations
|
|||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
|
|
||||||
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
|
from govoplan_access.backend.permissions.evaluator import scope_grants as _scope_grants
|
||||||
from govoplan_access.backend.permissions.evaluator import scopes_grant as _scopes_grant
|
|
||||||
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
from govoplan_core.core.modules import PermissionDefinition, PermissionLevel, RoleTemplate
|
||||||
from govoplan_core.security.module_permissions import compatible_required_scopes
|
from govoplan_core.security.module_permissions import compatible_required_scopes
|
||||||
|
from govoplan_core.security.permissions import ALL_PERMISSIONS as CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||||
|
from govoplan_core.security.permissions import PermissionDefinition as CoreLegacyPermissionDefinition
|
||||||
|
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||||
|
|
||||||
|
|
||||||
LEGACY_SCOPE_ALIASES: dict[str, frozenset[str]] = {
|
def _legacy_permission(permission: CoreLegacyPermissionDefinition) -> PermissionDefinition:
|
||||||
"campaign:write": frozenset({
|
parts = permission.scope.split(":", 2)
|
||||||
"campaign:create",
|
if len(parts) == 2:
|
||||||
"campaign:update",
|
module_id, action = parts
|
||||||
"campaign:copy",
|
resource = module_id
|
||||||
"campaign:archive",
|
else:
|
||||||
"campaign:delete",
|
module_id, resource, action = parts
|
||||||
"campaign:share",
|
|
||||||
"recipients:read",
|
|
||||||
"recipients:write",
|
|
||||||
"recipients:import",
|
|
||||||
}),
|
|
||||||
"attachments:read": frozenset({"files:read", "files:download"}),
|
|
||||||
"attachments:write": frozenset({"files:upload", "files:organize", "files:share", "files:delete"}),
|
|
||||||
"admin:users": frozenset({
|
|
||||||
"admin:users:read",
|
|
||||||
"admin:users:create",
|
|
||||||
"admin:users:update",
|
|
||||||
"admin:users:suspend",
|
|
||||||
"admin:groups:read",
|
|
||||||
"admin:groups:write",
|
|
||||||
"admin:groups:manage_members",
|
|
||||||
"admin:roles:read",
|
|
||||||
"admin:roles:write",
|
|
||||||
"admin:roles:assign",
|
|
||||||
}),
|
|
||||||
"admin:users:write": frozenset({
|
|
||||||
"admin:users:create",
|
|
||||||
"admin:users:update",
|
|
||||||
"admin:users:suspend",
|
|
||||||
"admin:roles:assign",
|
|
||||||
"admin:groups:manage_members",
|
|
||||||
}),
|
|
||||||
"admin:api_keys:write": frozenset({"admin:api_keys:create", "admin:api_keys:revoke"}),
|
|
||||||
"admin:settings": frozenset({
|
|
||||||
"admin:settings:read",
|
|
||||||
"admin:settings:write",
|
|
||||||
"admin:api_keys:read",
|
|
||||||
"admin:api_keys:create",
|
|
||||||
"admin:api_keys:revoke",
|
|
||||||
}),
|
|
||||||
"system:tenants:write": frozenset({"system:tenants:create", "system:tenants:update", "system:tenants:suspend"}),
|
|
||||||
"system:access:write": frozenset({
|
|
||||||
"system:access:assign",
|
|
||||||
"system:roles:assign",
|
|
||||||
"system:accounts:create",
|
|
||||||
"system:accounts:update",
|
|
||||||
"system:accounts:suspend",
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str, category: str, level: PermissionLevel) -> PermissionDefinition:
|
|
||||||
module_id, resource, action = scope.split(":", 2)
|
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
scope=scope,
|
scope=permission.scope,
|
||||||
label=label,
|
label=permission.label,
|
||||||
description=description,
|
description=permission.description,
|
||||||
category=category,
|
category=permission.category,
|
||||||
level=level,
|
level=permission.level, # type: ignore[arg-type]
|
||||||
module_id=module_id,
|
module_id=module_id,
|
||||||
resource=resource,
|
resource=resource,
|
||||||
action=action,
|
action=action,
|
||||||
@@ -75,43 +30,9 @@ def _permission(scope: str, label: str, description: str, category: str, level:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = (
|
LEGACY_PERMISSION_DEFINITIONS: tuple[PermissionDefinition, ...] = tuple(
|
||||||
_permission("admin:users:read", "View users", "List tenant users and their effective access.", "Tenant administration", "tenant"),
|
_legacy_permission(permission)
|
||||||
_permission("admin:users:create", "Create users", "Create tenant memberships for accounts.", "Tenant administration", "tenant"),
|
for permission in CORE_LEGACY_PERMISSION_DEFINITIONS
|
||||||
_permission("admin:users:update", "Update users", "Edit non-access membership metadata.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:users:suspend", "Suspend users", "Activate or suspend tenant memberships.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:groups:read", "View groups", "List tenant groups, members and assigned roles.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:groups:write", "Manage groups", "Create or edit tenant group definitions.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:groups:manage_members", "Manage group members", "Add and remove users from tenant groups.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:roles:read", "View roles", "Inspect role definitions and the permission catalogue.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:roles:write", "Define roles", "Create and edit assignable tenant role definitions.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:roles:assign", "Assign roles", "Assign tenant roles to users or groups within delegation limits.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:api_keys:read", "View API keys", "List tenant API keys without revealing their secret.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:api_keys:create", "Create API keys", "Create tenant-local API keys within the owner's delegation limits.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:api_keys:revoke", "Revoke API keys", "Revoke tenant-local API keys.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:settings:read", "View tenant settings", "Read tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration", "tenant"),
|
|
||||||
_permission("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration", "tenant"),
|
|
||||||
_permission("system:tenants:read", "View all tenants", "List tenants and system-wide membership counts.", "System administration", "system"),
|
|
||||||
_permission("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
|
|
||||||
_permission("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
|
|
||||||
_permission("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
|
|
||||||
_permission("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
|
|
||||||
_permission("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
|
|
||||||
_permission("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
|
|
||||||
_permission("system:accounts:suspend", "Suspend accounts", "Activate or suspend global login accounts while preserving a system owner.", "System administration", "system"),
|
|
||||||
_permission("system:roles:read", "View system roles", "Inspect instance-wide role definitions and their permissions.", "System administration", "system"),
|
|
||||||
_permission("system:roles:write", "Define system roles", "Create and edit instance-wide role definitions within delegation limits.", "System administration", "system"),
|
|
||||||
_permission("system:roles:assign", "Assign system roles", "Assign instance-wide roles to accounts while preserving a system owner.", "System administration", "system"),
|
|
||||||
_permission("system:access:read", "View system access", "Compatibility scope for listing accounts with instance-wide roles.", "System administration", "system"),
|
|
||||||
_permission("system:access:assign", "Assign system access", "Compatibility scope for assigning instance-wide roles.", "System administration", "system"),
|
|
||||||
_permission("system:audit:read", "View system audit", "Read audit records across tenants.", "System administration", "system"),
|
|
||||||
_permission("system:settings:read", "View system settings", "Read instance defaults and tenant-governance defaults.", "System administration", "system"),
|
|
||||||
_permission("system:settings:write", "Manage system settings", "Change instance defaults and tenant-governance defaults.", "System administration", "system"),
|
|
||||||
_permission("system:maintenance:access", "Access during maintenance", "Use the system while maintenance mode is active.", "System administration", "system"),
|
|
||||||
_permission("system:governance:read", "View governance templates", "Inspect centrally managed group and role definitions.", "System administration", "system"),
|
|
||||||
_permission("system:governance:write", "Manage governance templates", "Create and assign centrally managed group and role definitions.", "System administration", "system"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,12 +67,12 @@ def role_templates_for_level(level: PermissionLevel) -> tuple[RoleTemplate, ...]
|
|||||||
return tuple(template for template in role_templates() if template.level == level)
|
return tuple(template for template in role_templates() if template.level == level)
|
||||||
|
|
||||||
|
|
||||||
def scope_grants(granted: str, required: str) -> bool:
|
def scope_grants(granted: str, required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||||
catalog = permission_map(include_legacy=True)
|
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||||
if _scope_grants(granted, required, catalog=catalog):
|
if _scope_grants(granted, required, catalog=catalog):
|
||||||
return True
|
return True
|
||||||
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
|
for alias in LEGACY_SCOPE_ALIASES.get(granted, frozenset()):
|
||||||
if scope_grants(alias, required):
|
if scope_grants(alias, required, catalog=catalog):
|
||||||
return True
|
return True
|
||||||
return any(
|
return any(
|
||||||
_scope_grants(granted, alias, catalog=catalog)
|
_scope_grants(granted, alias, catalog=catalog)
|
||||||
@@ -160,8 +81,9 @@ def scope_grants(granted: str, required: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def scopes_grant(scopes: Iterable[str], required: str) -> bool:
|
def scopes_grant(scopes: Iterable[str], required: str, *, catalog: Mapping[str, PermissionDefinition] | None = None) -> bool:
|
||||||
return any(scope_grants(scope, required) for scope in scopes)
|
catalog = catalog if catalog is not None else permission_map(include_legacy=True)
|
||||||
|
return any(scope_grants(scope, required, catalog=catalog) for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
|
def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> list[str]:
|
||||||
@@ -173,12 +95,17 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
|
|||||||
expanded.add(scope)
|
expanded.add(scope)
|
||||||
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
|
for alias in LEGACY_SCOPE_ALIASES.get(scope, frozenset()):
|
||||||
expanded.add(alias)
|
expanded.add(alias)
|
||||||
matched = {candidate for candidate in catalog if scope_grants(scope, candidate)}
|
matched = {candidate for candidate in catalog if scope_grants(scope, candidate, catalog=catalog)}
|
||||||
expanded.update(matched)
|
expanded.update(matched)
|
||||||
for alias in compatible_required_scopes(scope):
|
for alias in compatible_required_scopes(scope):
|
||||||
if alias != scope:
|
if alias != scope:
|
||||||
expanded.add(alias)
|
expanded.add(alias)
|
||||||
if include_unknown and not matched:
|
# Preserve explicitly granted scopes in the presentation set. A
|
||||||
|
# canonical module scope can still match its legacy compatibility
|
||||||
|
# alias when the providing module is not loaded; treating that match
|
||||||
|
# as proof that the original scope is known would otherwise replace
|
||||||
|
# the canonical grant with only the deprecated alias.
|
||||||
|
if include_unknown or scope in catalog:
|
||||||
expanded.add(scope)
|
expanded.add(scope)
|
||||||
return sorted(expanded)
|
return sorted(expanded)
|
||||||
|
|
||||||
@@ -186,7 +113,8 @@ def expand_scopes(scopes: Iterable[str], *, include_unknown: bool = True) -> lis
|
|||||||
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
|
def effective_permission_scopes(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> set[str]:
|
||||||
candidates = _effective_permission_candidates(level=level)
|
candidates = _effective_permission_candidates(level=level)
|
||||||
granted = list(scopes)
|
granted = list(scopes)
|
||||||
return {scope for scope in candidates if scopes_grant(granted, scope)}
|
catalog = permission_map(include_legacy=True)
|
||||||
|
return {scope for scope in candidates if scopes_grant(granted, scope, catalog=catalog)}
|
||||||
|
|
||||||
|
|
||||||
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
|
def effective_permission_count(scopes: Iterable[str], *, level: PermissionLevel | None = None) -> int:
|
||||||
@@ -207,7 +135,11 @@ def _effective_permission_candidates(*, level: PermissionLevel | None = None) ->
|
|||||||
continue
|
continue
|
||||||
if level is not None and definition.level != level:
|
if level is not None and definition.level != level:
|
||||||
continue
|
continue
|
||||||
if any(scope_grants(active_scope, scope) or scope_grants(scope, active_scope) for active_scope in active_scopes):
|
if any(
|
||||||
|
scope_grants(active_scope, scope, catalog=full_catalog)
|
||||||
|
or scope_grants(scope, active_scope, catalog=full_catalog)
|
||||||
|
for active_scope in active_scopes
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
candidates.add(scope)
|
candidates.add(scope)
|
||||||
return candidates
|
return candidates
|
||||||
@@ -231,7 +163,7 @@ def validate_permissions(scopes: Iterable[str], *, level: PermissionLevel) -> li
|
|||||||
expanded.add(alias)
|
expanded.add(alias)
|
||||||
continue
|
continue
|
||||||
if scope.endswith(":*"):
|
if scope.endswith(":*"):
|
||||||
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate)}
|
matching = {candidate for candidate, definition in catalog.items() if definition.level == level and scope_grants(scope, candidate, catalog=catalog)}
|
||||||
if matching:
|
if matching:
|
||||||
expanded.add(scope)
|
expanded.add(scope)
|
||||||
continue
|
continue
|
||||||
@@ -272,8 +204,9 @@ def delegateable_system_scopes(scopes: Iterable[str]) -> set[str]:
|
|||||||
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
|
def intersect_api_key_scopes(user_scopes: Iterable[str], key_scopes: Iterable[str]) -> list[str]:
|
||||||
user = list(user_scopes)
|
user = list(user_scopes)
|
||||||
key = list(key_scopes)
|
key = list(key_scopes)
|
||||||
tenant_scopes = {scope for scope, definition in permission_map(include_legacy=True).items() if definition.level == "tenant"}
|
catalog = permission_map(include_legacy=True)
|
||||||
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope) and scopes_grant(key, scope)}
|
tenant_scopes = {scope for scope, definition in catalog.items() if definition.level == "tenant"}
|
||||||
|
allowed = {scope for scope in tenant_scopes if scopes_grant(user, scope, catalog=catalog) and scopes_grant(key, scope, catalog=catalog)}
|
||||||
user_raw = set(expand_scopes(user))
|
user_raw = set(expand_scopes(user))
|
||||||
key_raw = set(expand_scopes(key))
|
key_raw = set(expand_scopes(key))
|
||||||
allowed.update(
|
allowed.update(
|
||||||
|
|||||||
262
src/govoplan_access/backend/reference_options.py
Normal file
262
src/govoplan_access/backend/reference_options.py
Normal 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"]
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
358
src/govoplan_access/backend/security/login_throttle.py
Normal file
358
src/govoplan_access/backend/security/login_throttle.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from redis import Redis
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_REDIS_INCREMENT_SCRIPT = """
|
||||||
|
local count = redis.call('INCR', KEYS[1])
|
||||||
|
if count == 1 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
local ttl = redis.call('TTL', KEYS[1])
|
||||||
|
return {count, ttl}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AttemptBucket:
|
||||||
|
count: int = 0
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LoginThrottleDecision:
|
||||||
|
allowed: bool
|
||||||
|
retry_after_seconds: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class LoginAttemptStore(Protocol):
|
||||||
|
def read(self, key: str) -> AttemptBucket: ...
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket: ...
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryLoginAttemptStore:
|
||||||
|
"""Bounded process-local fallback for development and Redis outages."""
|
||||||
|
|
||||||
|
def __init__(self, *, max_entries: int = 10_000) -> None:
|
||||||
|
self._entries: dict[str, tuple[int, float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._max_entries = max(2, max_entries)
|
||||||
|
|
||||||
|
def read(self, key: str) -> AttemptBucket:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
return AttemptBucket()
|
||||||
|
count, expires_at = entry
|
||||||
|
return AttemptBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
entry = self._active_entry(key, now=now)
|
||||||
|
if entry is None:
|
||||||
|
self._make_room(now=now, incoming_key=key)
|
||||||
|
count = 1
|
||||||
|
expires_at = now + window_seconds
|
||||||
|
else:
|
||||||
|
count = entry[0] + 1
|
||||||
|
expires_at = entry[1]
|
||||||
|
self._entries[key] = (count, expires_at)
|
||||||
|
return AttemptBucket(
|
||||||
|
count=count,
|
||||||
|
retry_after_seconds=max(1, int(expires_at - now + 0.999)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
|
||||||
|
def _active_entry(self, key: str, *, now: float) -> tuple[int, float] | None:
|
||||||
|
entry = self._entries.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
if entry[1] <= now:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
return None
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _make_room(self, *, now: float, incoming_key: str) -> None:
|
||||||
|
if incoming_key in self._entries or len(self._entries) < self._max_entries:
|
||||||
|
return
|
||||||
|
expired = [key for key, (_, expires_at) in self._entries.items() if expires_at <= now]
|
||||||
|
for key in expired:
|
||||||
|
self._entries.pop(key, None)
|
||||||
|
while len(self._entries) >= self._max_entries:
|
||||||
|
self._entries.pop(next(iter(self._entries)))
|
||||||
|
|
||||||
|
|
||||||
|
class RedisLoginAttemptStore:
|
||||||
|
"""Redis-backed fixed-window counters shared by all API workers."""
|
||||||
|
|
||||||
|
def __init__(self, redis_url: str) -> None:
|
||||||
|
self._client = Redis.from_url(
|
||||||
|
redis_url,
|
||||||
|
decode_responses=True,
|
||||||
|
socket_connect_timeout=0.25,
|
||||||
|
socket_timeout=0.25,
|
||||||
|
health_check_interval=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def read(self, key: str) -> AttemptBucket:
|
||||||
|
pipeline = self._client.pipeline(transaction=False)
|
||||||
|
pipeline.get(key)
|
||||||
|
pipeline.ttl(key)
|
||||||
|
raw_count, raw_ttl = pipeline.execute()
|
||||||
|
count = int(raw_count or 0)
|
||||||
|
ttl = int(raw_ttl or 0)
|
||||||
|
return AttemptBucket(count=count, retry_after_seconds=max(0, ttl))
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||||
|
result = self._client.eval(_REDIS_INCREMENT_SCRIPT, 1, key, window_seconds)
|
||||||
|
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
||||||
|
raise RedisError("Unexpected login throttle response from Redis")
|
||||||
|
return AttemptBucket(
|
||||||
|
count=int(result[0]),
|
||||||
|
retry_after_seconds=max(1, int(result[1])),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._client.delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
class ResilientLoginAttemptStore:
|
||||||
|
"""Prefer the distributed store and fail safely to a local bounded store."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
primary: LoginAttemptStore | None,
|
||||||
|
fallback: LoginAttemptStore,
|
||||||
|
*,
|
||||||
|
retry_seconds: int = 30,
|
||||||
|
) -> None:
|
||||||
|
self._primary = primary
|
||||||
|
self._fallback = fallback
|
||||||
|
self._retry_seconds = max(1, retry_seconds)
|
||||||
|
self._primary_unavailable_until = 0.0
|
||||||
|
self._state_lock = threading.Lock()
|
||||||
|
|
||||||
|
def read(self, key: str) -> AttemptBucket:
|
||||||
|
fallback_result = self._fallback.read(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return fallback_result
|
||||||
|
try:
|
||||||
|
return _stricter_bucket(primary.read(key), fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
def increment(self, key: str, *, window_seconds: int) -> AttemptBucket:
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is not None:
|
||||||
|
fallback_result = self._fallback.increment(key, window_seconds=window_seconds)
|
||||||
|
try:
|
||||||
|
# Mirror the active process's failures so a later Redis outage
|
||||||
|
# or recovery cannot restart its protection window from zero.
|
||||||
|
primary_result = primary.increment(key, window_seconds=window_seconds)
|
||||||
|
return _stricter_bucket(primary_result, fallback_result)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
return fallback_result
|
||||||
|
return self._fallback.increment(key, window_seconds=window_seconds)
|
||||||
|
|
||||||
|
def delete(self, key: str) -> None:
|
||||||
|
self._fallback.delete(key)
|
||||||
|
primary = self._available_primary()
|
||||||
|
if primary is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
primary.delete(key)
|
||||||
|
except (RedisError, OSError, TimeoutError, ConnectionError) as exc:
|
||||||
|
self._mark_primary_unavailable(exc)
|
||||||
|
|
||||||
|
def _available_primary(self) -> LoginAttemptStore | None:
|
||||||
|
if self._primary is None:
|
||||||
|
return None
|
||||||
|
with self._state_lock:
|
||||||
|
if time.monotonic() < self._primary_unavailable_until:
|
||||||
|
return None
|
||||||
|
return self._primary
|
||||||
|
|
||||||
|
def _mark_primary_unavailable(self, exc: Exception) -> None:
|
||||||
|
should_log = False
|
||||||
|
with self._state_lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
if now >= self._primary_unavailable_until:
|
||||||
|
should_log = True
|
||||||
|
self._primary_unavailable_until = now + self._retry_seconds
|
||||||
|
if should_log:
|
||||||
|
logger.warning(
|
||||||
|
"Redis login throttling is unavailable; using the process-local fallback (%s)",
|
||||||
|
type(exc).__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stricter_bucket(first: AttemptBucket, second: AttemptBucket) -> AttemptBucket:
|
||||||
|
return AttemptBucket(
|
||||||
|
count=max(first.count, second.count),
|
||||||
|
retry_after_seconds=max(first.retry_after_seconds, second.retry_after_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottle:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
store: LoginAttemptStore,
|
||||||
|
*,
|
||||||
|
identity_limit: int,
|
||||||
|
client_limit: int,
|
||||||
|
window_seconds: int,
|
||||||
|
key_prefix: str = "govoplan:access:login:v1",
|
||||||
|
) -> None:
|
||||||
|
self._store = store
|
||||||
|
self._identity_limit = max(1, identity_limit)
|
||||||
|
self._client_limit = max(1, client_limit)
|
||||||
|
self._window_seconds = max(1, window_seconds)
|
||||||
|
self._key_prefix = key_prefix.rstrip(":")
|
||||||
|
|
||||||
|
def check(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
tenant_slug: str | None,
|
||||||
|
client_address: str | None,
|
||||||
|
) -> LoginThrottleDecision:
|
||||||
|
return self._decision(
|
||||||
|
self._buckets(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
client_address=client_address,
|
||||||
|
),
|
||||||
|
increment=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_failure(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
tenant_slug: str | None,
|
||||||
|
client_address: str | None,
|
||||||
|
) -> LoginThrottleDecision:
|
||||||
|
return self._decision(
|
||||||
|
self._buckets(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
tenant_slug=tenant_slug,
|
||||||
|
client_address=client_address,
|
||||||
|
),
|
||||||
|
increment=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def record_success(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
tenant_slug: str | None,
|
||||||
|
) -> None:
|
||||||
|
del tenant_slug
|
||||||
|
identity_key, _ = self._keys(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
client_address=None,
|
||||||
|
)
|
||||||
|
self._store.delete(identity_key)
|
||||||
|
|
||||||
|
def _decision(
|
||||||
|
self,
|
||||||
|
buckets: tuple[tuple[str, int], ...],
|
||||||
|
*,
|
||||||
|
increment: bool,
|
||||||
|
) -> LoginThrottleDecision:
|
||||||
|
blocked_retry_after = 0
|
||||||
|
for key, limit in buckets:
|
||||||
|
state = (
|
||||||
|
self._store.increment(key, window_seconds=self._window_seconds)
|
||||||
|
if increment
|
||||||
|
else self._store.read(key)
|
||||||
|
)
|
||||||
|
if state.count >= limit:
|
||||||
|
blocked_retry_after = max(
|
||||||
|
blocked_retry_after,
|
||||||
|
max(1, state.retry_after_seconds),
|
||||||
|
)
|
||||||
|
return LoginThrottleDecision(
|
||||||
|
allowed=blocked_retry_after == 0,
|
||||||
|
retry_after_seconds=blocked_retry_after,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _buckets(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
tenant_slug: str | None,
|
||||||
|
client_address: str | None,
|
||||||
|
) -> tuple[tuple[str, int], ...]:
|
||||||
|
# Accounts and their passwords are global login identities. Do not put
|
||||||
|
# the caller-supplied tenant slug into the identity key: rotating fake
|
||||||
|
# slugs must not bypass the account-level limit.
|
||||||
|
del tenant_slug
|
||||||
|
identity_key, client_key = self._keys(
|
||||||
|
normalized_email=normalized_email,
|
||||||
|
client_address=client_address,
|
||||||
|
)
|
||||||
|
buckets = [(identity_key, self._identity_limit)]
|
||||||
|
if client_key is not None:
|
||||||
|
buckets.append((client_key, self._client_limit))
|
||||||
|
return tuple(buckets)
|
||||||
|
|
||||||
|
def _keys(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
normalized_email: str,
|
||||||
|
client_address: str | None,
|
||||||
|
) -> tuple[str, str | None]:
|
||||||
|
identity = normalized_email.strip().casefold()
|
||||||
|
identity_digest = hashlib.sha256(identity.encode()).hexdigest()
|
||||||
|
identity_key = f"{self._key_prefix}:identity:{identity_digest}"
|
||||||
|
if not client_address:
|
||||||
|
return identity_key, None
|
||||||
|
client_digest = hashlib.sha256(client_address.strip().casefold().encode()).hexdigest()
|
||||||
|
return identity_key, f"{self._key_prefix}:client:{client_digest}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_login_throttle(
|
||||||
|
*,
|
||||||
|
redis_url: str | None,
|
||||||
|
identity_limit: int,
|
||||||
|
client_limit: int,
|
||||||
|
window_seconds: int,
|
||||||
|
redis_retry_seconds: int,
|
||||||
|
) -> LoginThrottle:
|
||||||
|
redis_store = RedisLoginAttemptStore(redis_url) if redis_url and redis_url.strip() else None
|
||||||
|
resilient_store = ResilientLoginAttemptStore(
|
||||||
|
redis_store,
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
retry_seconds=redis_retry_seconds,
|
||||||
|
)
|
||||||
|
return LoginThrottle(
|
||||||
|
resilient_store,
|
||||||
|
identity_limit=identity_limit,
|
||||||
|
client_limit=client_limit,
|
||||||
|
window_seconds=window_seconds,
|
||||||
|
)
|
||||||
@@ -9,6 +9,14 @@ _ALGORITHM = "pbkdf2_sha256"
|
|||||||
_DEFAULT_ITERATIONS = 260_000
|
_DEFAULT_ITERATIONS = 260_000
|
||||||
_SALT_BYTES = 16
|
_SALT_BYTES = 16
|
||||||
|
|
||||||
|
# A valid, fixed-cost hash used when a login identity has no local password hash.
|
||||||
|
# Its plaintext value is intentionally irrelevant; the hash only keeps failed
|
||||||
|
# login attempts on the same verification path as existing local accounts.
|
||||||
|
DUMMY_PASSWORD_HASH = (
|
||||||
|
"pbkdf2_sha256$260000$Z292b3BsYW4tZHVtbXktdjE=" # noqa: S105 # nosec B105 - non-account timing equalizer.
|
||||||
|
"$uWgE7ht8wO6cotOqNKK2yNomPt57gstVss5ben5gTbw="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
|
def hash_password(password: str, *, iterations: int = _DEFAULT_ITERATIONS) -> str:
|
||||||
salt = os.urandom(_SALT_BYTES)
|
salt = os.urandom(_SALT_BYTES)
|
||||||
@@ -37,4 +45,3 @@ def verify_password(password: str, encoded: str | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
|
||||||
return hmac.compare_digest(actual, expected)
|
return hmac.compare_digest(actual, expected)
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, 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 (
|
||||||
@@ -18,8 +19,8 @@ from govoplan_access.backend.db.models import (
|
|||||||
UserGroupMembership,
|
UserGroupMembership,
|
||||||
UserRoleAssignment,
|
UserRoleAssignment,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.semantic import collect_function_roles
|
from govoplan_access.backend.semantic import collect_function_authorization_context, collect_function_roles
|
||||||
from govoplan_access.backend.permissions.catalog import expand_scopes
|
from govoplan_access.backend.permissions.catalog import expand_scopes, role_templates_for_level
|
||||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
|
|
||||||
SESSION_RANDOM_BYTES = 32
|
SESSION_RANDOM_BYTES = 32
|
||||||
@@ -33,6 +34,16 @@ class CreatedSession:
|
|||||||
csrf_token: str
|
csrf_token: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class UserAuthorizationContext:
|
||||||
|
tenant_roles: list[Role]
|
||||||
|
system_roles: list[Role]
|
||||||
|
groups: list[Group]
|
||||||
|
function_assignment_ids: tuple[str, ...]
|
||||||
|
function_delegation_ids: tuple[str, ...]
|
||||||
|
scopes: list[str]
|
||||||
|
|
||||||
|
|
||||||
def generate_session_token() -> str:
|
def generate_session_token() -> str:
|
||||||
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
|
return generate_secret("ms_", random_bytes=SESSION_RANDOM_BYTES)
|
||||||
|
|
||||||
@@ -93,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
|
||||||
|
|
||||||
|
|
||||||
@@ -172,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())
|
||||||
|
|
||||||
|
|
||||||
@@ -199,16 +234,115 @@ def collect_user_groups(session: Session, user: User) -> list[Group]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_user_authorization_context(
|
||||||
|
session: Session,
|
||||||
|
user: User,
|
||||||
|
*,
|
||||||
|
account: Account | None = None,
|
||||||
|
include_system: bool = True,
|
||||||
|
extra_roles: Iterable[Role] = (),
|
||||||
|
) -> UserAuthorizationContext:
|
||||||
|
account = account or user.account
|
||||||
|
roles_by_id: dict[str, Role] = {role.id: role for role in collect_direct_user_roles(session, user)}
|
||||||
|
groups = collect_user_groups(session, user)
|
||||||
|
group_ids = [group.id for group in groups]
|
||||||
|
if group_ids:
|
||||||
|
group_roles = (
|
||||||
|
session.query(Role)
|
||||||
|
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
|
||||||
|
.filter(
|
||||||
|
GroupRoleAssignment.tenant_id == user.tenant_id,
|
||||||
|
GroupRoleAssignment.group_id.in_(sorted(group_ids)),
|
||||||
|
Role.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
.order_by(Role.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for role in group_roles:
|
||||||
|
roles_by_id[role.id] = role
|
||||||
|
|
||||||
|
function_context = collect_function_authorization_context(session, user)
|
||||||
|
for role in function_context.roles:
|
||||||
|
roles_by_id[role.id] = role
|
||||||
|
for role in extra_roles:
|
||||||
|
roles_by_id[role.id] = role
|
||||||
|
for role in _materialized_default_authenticated_roles(session, user):
|
||||||
|
roles_by_id[role.id] = role
|
||||||
|
|
||||||
|
tenant_roles = list(roles_by_id.values())
|
||||||
|
system_roles = collect_system_roles(session, account) if include_system and account is not None else []
|
||||||
|
default_slugs = _default_authenticated_slugs()
|
||||||
|
scopes = {
|
||||||
|
scope
|
||||||
|
for role in tenant_roles
|
||||||
|
if role.slug not in default_slugs
|
||||||
|
for scope in (role.permissions or [])
|
||||||
|
}
|
||||||
|
scopes.update(
|
||||||
|
scope
|
||||||
|
for role in system_roles
|
||||||
|
for scope in (role.permissions or [])
|
||||||
|
)
|
||||||
|
scopes.update(_default_authenticated_scopes())
|
||||||
|
return UserAuthorizationContext(
|
||||||
|
tenant_roles=tenant_roles,
|
||||||
|
system_roles=system_roles,
|
||||||
|
groups=groups,
|
||||||
|
function_assignment_ids=function_context.assignment_ids,
|
||||||
|
function_delegation_ids=function_context.delegation_ids,
|
||||||
|
scopes=expand_scopes(scopes),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
|
def collect_user_scopes(session: Session, user: User, *, include_system: bool = True) -> list[str]:
|
||||||
scopes: set[str] = set()
|
scopes = _default_authenticated_scopes()
|
||||||
|
default_slugs = _default_authenticated_slugs()
|
||||||
for role in collect_user_roles(session, user):
|
for role in collect_user_roles(session, user):
|
||||||
scopes.update(role.permissions or [])
|
if role.slug not in default_slugs:
|
||||||
|
scopes.update(role.permissions or [])
|
||||||
if include_system and user.account:
|
if include_system and user.account:
|
||||||
for role in collect_system_roles(session, user.account):
|
for role in collect_system_roles(session, user.account):
|
||||||
scopes.update(role.permissions or [])
|
scopes.update(role.permissions or [])
|
||||||
return expand_scopes(scopes)
|
return expand_scopes(scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_authenticated_slugs() -> set[str]:
|
||||||
|
return {
|
||||||
|
template.slug
|
||||||
|
for template in role_templates_for_level("tenant")
|
||||||
|
if template.default_authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_authenticated_scopes() -> set[str]:
|
||||||
|
return {
|
||||||
|
scope
|
||||||
|
for template in role_templates_for_level("tenant")
|
||||||
|
if template.default_authenticated
|
||||||
|
for scope in template.permissions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _materialized_default_authenticated_roles(
|
||||||
|
session: Session,
|
||||||
|
user: User,
|
||||||
|
) -> list[Role]:
|
||||||
|
"""Return the optional database projection without mutating auth reads."""
|
||||||
|
|
||||||
|
default_slugs = _default_authenticated_slugs()
|
||||||
|
if not default_slugs:
|
||||||
|
return []
|
||||||
|
return (
|
||||||
|
session.query(Role)
|
||||||
|
.filter(
|
||||||
|
Role.tenant_id == user.tenant_id,
|
||||||
|
Role.slug.in_(default_slugs),
|
||||||
|
)
|
||||||
|
.order_by(Role.name.asc(), Role.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
|
def collect_tenant_memberships(session: Session, account: Account) -> list[tuple[User, Tenant]]:
|
||||||
return (
|
return (
|
||||||
session.query(User, Tenant)
|
session.query(User, Tenant)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -23,6 +24,13 @@ from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, Organizati
|
|||||||
from govoplan_core.security.time import utc_now
|
from govoplan_core.security.time import utc_now
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FunctionAuthorizationContext:
|
||||||
|
assignment_ids: tuple[str, ...]
|
||||||
|
delegation_ids: tuple[str, ...]
|
||||||
|
roles: tuple[Role, ...]
|
||||||
|
|
||||||
|
|
||||||
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
|
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
|
||||||
return (
|
return (
|
||||||
session.query(Identity)
|
session.query(Identity)
|
||||||
@@ -169,6 +177,56 @@ def collect_function_roles(session: Session, user: User) -> list[Role]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_function_authorization_context(session: Session, user: User) -> FunctionAuthorizationContext:
|
||||||
|
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||||
|
delegations = active_function_delegations_for_account(
|
||||||
|
session,
|
||||||
|
user.account_id,
|
||||||
|
tenant_id=user.tenant_id,
|
||||||
|
modes=("delegate", "act_in_place"),
|
||||||
|
)
|
||||||
|
direct_assignment_ids = {assignment.id for assignment in assignments}
|
||||||
|
delegated_role_assignment_ids = {
|
||||||
|
delegation.function_assignment_id
|
||||||
|
for delegation in delegations
|
||||||
|
if delegation.mode == "delegate"
|
||||||
|
}
|
||||||
|
role_assignment_ids = direct_assignment_ids | delegated_role_assignment_ids
|
||||||
|
function_ids = {assignment.function_id for assignment in assignments}
|
||||||
|
|
||||||
|
missing_role_assignment_ids = delegated_role_assignment_ids - direct_assignment_ids
|
||||||
|
if missing_role_assignment_ids:
|
||||||
|
function_ids.update(
|
||||||
|
row[0]
|
||||||
|
for row in session.query(FunctionAssignment.function_id)
|
||||||
|
.filter(
|
||||||
|
FunctionAssignment.tenant_id == user.tenant_id,
|
||||||
|
FunctionAssignment.id.in_(sorted(missing_role_assignment_ids)),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
roles: tuple[Role, ...] = ()
|
||||||
|
if function_ids:
|
||||||
|
roles = tuple(
|
||||||
|
session.query(Role)
|
||||||
|
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||||
|
.filter(
|
||||||
|
FunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||||
|
FunctionRoleAssignment.function_id.in_(sorted(function_ids)),
|
||||||
|
Role.tenant_id == user.tenant_id,
|
||||||
|
)
|
||||||
|
.order_by(Role.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return FunctionAuthorizationContext(
|
||||||
|
assignment_ids=tuple(sorted(role_assignment_ids)),
|
||||||
|
delegation_ids=tuple(sorted(delegation.id for delegation in delegations)),
|
||||||
|
roles=roles,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def collect_external_function_roles(
|
def collect_external_function_roles(
|
||||||
session: Session,
|
session: Session,
|
||||||
user: User,
|
user: User,
|
||||||
|
|||||||
283
src/govoplan_access/backend/service_accounts.py
Normal file
283
src/govoplan_access/backend/service_accounts.py
Normal 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",
|
||||||
|
]
|
||||||
149
tests/test_admin_batch_helpers.py
Normal file
149
tests/test_admin_batch_helpers.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||||
|
from govoplan_access.backend.api.v1.admin_common import (
|
||||||
|
_accounts_by_user_id,
|
||||||
|
_group_member_ids_by_group_id,
|
||||||
|
_groups_by_user_id,
|
||||||
|
_roles_by_group_id,
|
||||||
|
_roles_by_user_id,
|
||||||
|
_tenant_role_assignment_counts,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.db.models import Account, ApiKey, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBatchHelperTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_access_admin_batch_maps_related_rows(self) -> None:
|
||||||
|
session = self.session
|
||||||
|
account = Account(id="account-1", email="ada@example.test", normalized_email="ada@example.test")
|
||||||
|
user = User(id="user-1", tenant_id="tenant-1", account_id=account.id, email=account.email)
|
||||||
|
group = Group(id="group-1", tenant_id="tenant-1", slug="clerks", name="Clerks")
|
||||||
|
role = Role(id="role-1", tenant_id="tenant-1", slug="reader", name="Reader", permissions=["admin:users:read"])
|
||||||
|
session.add_all([account, user, group, role])
|
||||||
|
session.flush()
|
||||||
|
session.add(UserGroupMembership(tenant_id="tenant-1", user_id=user.id, group_id=group.id))
|
||||||
|
session.add(UserRoleAssignment(tenant_id="tenant-1", user_id=user.id, role_id=role.id))
|
||||||
|
session.add(GroupRoleAssignment(tenant_id="tenant-1", group_id=group.id, role_id=role.id))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
accounts_by_user = _accounts_by_user_id(session, [user.id])
|
||||||
|
group_member_ids = _group_member_ids_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
|
||||||
|
groups_by_user = _groups_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
|
||||||
|
roles_by_group = _roles_by_group_id(session, tenant_id="tenant-1", group_ids=[group.id])
|
||||||
|
roles_by_user = _roles_by_user_id(session, tenant_id="tenant-1", user_ids=[user.id])
|
||||||
|
role_counts = _tenant_role_assignment_counts(session, [role.id])
|
||||||
|
|
||||||
|
self.assertEqual(accounts_by_user[user.id].id, account.id)
|
||||||
|
self.assertEqual(group_member_ids, {group.id: [user.id]})
|
||||||
|
self.assertEqual([item.id for item in groups_by_user[user.id]], [group.id])
|
||||||
|
self.assertEqual([item.id for item in roles_by_group[group.id]], [role.id])
|
||||||
|
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
||||||
|
self.assertEqual(role_counts, {role.id: (1, 1)})
|
||||||
|
|
||||||
|
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__":
|
||||||
|
unittest.main()
|
||||||
89
tests/test_admin_pagination.py
Normal file
89
tests/test_admin_pagination.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1.routes import (
|
||||||
|
_decode_full_delta_cursor,
|
||||||
|
_encode_full_delta_cursor,
|
||||||
|
_full_delta_page,
|
||||||
|
_page_query,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQuery:
|
||||||
|
def __init__(self, rows: list[int]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
self._offset = 0
|
||||||
|
self._limit: int | None = None
|
||||||
|
|
||||||
|
def order_by(self, *_args: object) -> FakeQuery:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self._rows)
|
||||||
|
|
||||||
|
def offset(self, value: int) -> FakeQuery:
|
||||||
|
clone = FakeQuery(self._rows)
|
||||||
|
clone._offset = value
|
||||||
|
clone._limit = self._limit
|
||||||
|
return clone
|
||||||
|
|
||||||
|
def limit(self, value: int) -> FakeQuery:
|
||||||
|
clone = FakeQuery(self._rows)
|
||||||
|
clone._offset = self._offset
|
||||||
|
clone._limit = value
|
||||||
|
return clone
|
||||||
|
|
||||||
|
def all(self) -> list[int]:
|
||||||
|
end = None if self._limit is None else self._offset + self._limit
|
||||||
|
return self._rows[self._offset:end]
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPaginationTests(unittest.TestCase):
|
||||||
|
def test_page_query_returns_page_and_metadata(self) -> None:
|
||||||
|
rows, metadata = _page_query(FakeQuery(list(range(7))), page=2, page_size=3)
|
||||||
|
|
||||||
|
self.assertEqual(rows, [3, 4, 5])
|
||||||
|
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
|
||||||
|
|
||||||
|
def test_full_delta_page_returns_cursor_until_last_page(self) -> None:
|
||||||
|
rows, metadata, watermark, has_more = _full_delta_page(
|
||||||
|
FakeQuery(list(range(7))),
|
||||||
|
page=2,
|
||||||
|
page_size=3,
|
||||||
|
scope="users",
|
||||||
|
snapshot_sequence=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rows, [3, 4, 5])
|
||||||
|
self.assertEqual(metadata, {"total": 7, "page": 2, "page_size": 3, "pages": 3})
|
||||||
|
self.assertEqual(watermark, "full:users:3:42")
|
||||||
|
self.assertTrue(has_more)
|
||||||
|
|
||||||
|
def test_full_delta_page_returns_sequence_watermark_on_last_page(self) -> None:
|
||||||
|
rows, metadata, watermark, has_more = _full_delta_page(
|
||||||
|
FakeQuery(list(range(7))),
|
||||||
|
page=3,
|
||||||
|
page_size=3,
|
||||||
|
scope="users",
|
||||||
|
snapshot_sequence=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rows, [6])
|
||||||
|
self.assertEqual(metadata, {"total": 7, "page": 3, "page_size": 3, "pages": 3})
|
||||||
|
self.assertEqual(watermark, "seq:42")
|
||||||
|
self.assertFalse(has_more)
|
||||||
|
|
||||||
|
def test_full_delta_cursor_round_trips_and_rejects_wrong_scope(self) -> None:
|
||||||
|
cursor = _encode_full_delta_cursor("users", page=3, snapshot_sequence=42)
|
||||||
|
|
||||||
|
self.assertEqual(_decode_full_delta_cursor(cursor, scope="users"), (3, 42))
|
||||||
|
self.assertIsNone(_decode_full_delta_cursor("seq:42", scope="users"))
|
||||||
|
with self.assertRaises(HTTPException):
|
||||||
|
_decode_full_delta_cursor(cursor, scope="groups")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
100
tests/test_auth_activity_touches.py
Normal file
100
tests/test_auth_activity_touches.py
Normal 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()
|
||||||
161
tests/test_auth_dependencies.py
Normal file
161
tests/test_auth_dependencies.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from govoplan_access.backend.auth.dependencies import (
|
||||||
|
_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.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
||||||
|
return Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"method": method,
|
||||||
|
"path": "/",
|
||||||
|
"headers": [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in headers],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthDependencyTests(unittest.TestCase):
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
|
||||||
|
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||||
|
request = request_for(headers=[("authorization", "Bearer session-token")])
|
||||||
|
|
||||||
|
self.assertEqual(_extract_token(request, "Bearer session-token", "api-key-token"), ("api-key-token", "api_key"))
|
||||||
|
|
||||||
|
def test_extract_token_supports_bearer_and_cookie_sources(self) -> None:
|
||||||
|
self.assertEqual(_extract_token(request_for(), "Bearer session-token", None), ("session-token", "bearer"))
|
||||||
|
|
||||||
|
cookie_request = request_for(headers=[("cookie", f"{settings.auth_session_cookie_name}=cookie-token")])
|
||||||
|
self.assertEqual(_extract_token(cookie_request, None, None), ("cookie-token", "cookie"))
|
||||||
|
|
||||||
|
def test_requires_csrf_only_for_mutating_methods(self) -> None:
|
||||||
|
self.assertFalse(_requires_csrf(request_for(method="GET")))
|
||||||
|
self.assertTrue(_requires_csrf(request_for(method="POST")))
|
||||||
|
|
||||||
|
def test_legacy_principal_resolver_rejects_missing_token_before_db_lookup(self) -> None:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
_resolve_legacy_principal_ref(request_for(), None, authorization=None, x_api_key=None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
||||||
|
|
||||||
|
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__":
|
||||||
|
unittest.main()
|
||||||
295
tests/test_automation_principal.py
Normal file
295
tests/test_automation_principal.py
Normal 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()
|
||||||
137
tests/test_default_authenticated_roles.py
Normal file
137
tests/test_default_authenticated_roles.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.admin.service import ensure_default_roles, set_user_roles
|
||||||
|
from govoplan_access.backend.db.models import Account, Role, User, UserRoleAssignment
|
||||||
|
from govoplan_access.backend.security.sessions import collect_user_authorization_context
|
||||||
|
from govoplan_core.core.modules import RoleTemplate
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
|
DOCS_READER = RoleTemplate(
|
||||||
|
slug="docs_reader",
|
||||||
|
name="Documentation reader",
|
||||||
|
description="Authenticated documentation baseline.",
|
||||||
|
permissions=("docs:documentation:read",),
|
||||||
|
default_authenticated=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultAuthenticatedRoleTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(self.engine)
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||||
|
self.session.add(self.tenant)
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _add_user(self, suffix: str) -> User:
|
||||||
|
account = Account(
|
||||||
|
id=f"account-{suffix}",
|
||||||
|
email=f"{suffix}@example.test",
|
||||||
|
normalized_email=f"{suffix}@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id=f"user-{suffix}",
|
||||||
|
tenant_id=self.tenant.id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
)
|
||||||
|
self.session.add_all([account, user])
|
||||||
|
self.session.flush()
|
||||||
|
return user
|
||||||
|
|
||||||
|
def test_materialized_default_role_is_implicit_and_cannot_be_assigned_or_removed(self) -> None:
|
||||||
|
first = self._add_user("first")
|
||||||
|
self._add_user("second")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.admin.service.role_templates_for_level",
|
||||||
|
return_value=(DOCS_READER,),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||||
|
return_value=(DOCS_READER,),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
roles = ensure_default_roles(self.session, self.tenant)
|
||||||
|
default_role = roles["docs_reader"]
|
||||||
|
self.assertFalse(default_role.is_assignable)
|
||||||
|
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||||
|
|
||||||
|
# The database row is only an administration projection. Even if
|
||||||
|
# stale or tampered, it must never broaden the manifest baseline.
|
||||||
|
default_role.permissions = ["mail:profile:write"]
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
set_user_roles(self.session, user=first, role_ids=[])
|
||||||
|
context = collect_user_authorization_context(
|
||||||
|
self.session,
|
||||||
|
first,
|
||||||
|
account=first.account,
|
||||||
|
include_system=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||||
|
self.assertIn("docs:documentation:read", context.scopes)
|
||||||
|
self.assertNotIn("mail:profile:write", context.scopes)
|
||||||
|
self.assertEqual([role.slug for role in context.tenant_roles], ["docs_reader"])
|
||||||
|
|
||||||
|
def test_first_authorized_request_grants_default_without_database_mutation(self) -> None:
|
||||||
|
user = self._add_user("new")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||||
|
return_value=(DOCS_READER,),
|
||||||
|
):
|
||||||
|
context = collect_user_authorization_context(
|
||||||
|
self.session,
|
||||||
|
user,
|
||||||
|
account=user.account,
|
||||||
|
include_system=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("docs:documentation:read", context.scopes)
|
||||||
|
self.assertEqual(context.tenant_roles, [])
|
||||||
|
self.assertEqual(self.session.query(Role).count(), 0)
|
||||||
|
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||||
|
self.assertFalse(self.session.new)
|
||||||
|
self.assertFalse(self.session.dirty)
|
||||||
|
|
||||||
|
user_id = user.id
|
||||||
|
self.session.commit()
|
||||||
|
self.session.close()
|
||||||
|
self.session = self.Session()
|
||||||
|
persisted_user = self.session.get(User, user_id)
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.security.sessions.role_templates_for_level",
|
||||||
|
return_value=(DOCS_READER,),
|
||||||
|
):
|
||||||
|
reopened_context = collect_user_authorization_context(
|
||||||
|
self.session,
|
||||||
|
persisted_user,
|
||||||
|
account=persisted_user.account,
|
||||||
|
include_system=False,
|
||||||
|
)
|
||||||
|
self.assertIn("docs:documentation:read", reopened_context.scopes)
|
||||||
|
self.assertEqual(self.session.query(Role).count(), 0)
|
||||||
|
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
87
tests/test_login_security.py
Normal file
87
tests/test_login_security.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1 import auth
|
||||||
|
from govoplan_access.backend.security.passwords import (
|
||||||
|
DUMMY_PASSWORD_HASH,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from govoplan_core.api.v1.schemas import LoginRequest
|
||||||
|
|
||||||
|
|
||||||
|
class LoginSecurityTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _session_with_account(account: object | None) -> MagicMock:
|
||||||
|
session = MagicMock()
|
||||||
|
session.query.return_value.filter.return_value.one_or_none.return_value = (
|
||||||
|
account
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def test_absent_identity_verifies_a_valid_dummy_hash_before_generic_failure(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
self.assertTrue(verify_password("not-a-user-password", DUMMY_PASSWORD_HASH))
|
||||||
|
payload = LoginRequest(
|
||||||
|
email="missing@example.test", password="attempted-password"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(auth, "verify_password", return_value=True) as verifier:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_login_user(self._session_with_account(None), payload)
|
||||||
|
|
||||||
|
verifier.assert_called_once_with(payload.password, DUMMY_PASSWORD_HASH)
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
|
def test_present_identity_verifies_account_hash_before_same_generic_failure(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
account_hash = "pbkdf2_sha256$260000$account-salt$account-digest"
|
||||||
|
account = SimpleNamespace(password_hash=account_hash)
|
||||||
|
payload = LoginRequest(email="known@example.test", password="wrong-password")
|
||||||
|
|
||||||
|
with patch.object(auth, "verify_password", return_value=False) as verifier:
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
verifier.assert_called_once_with(payload.password, account_hash)
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
|
def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None:
|
||||||
|
account = SimpleNamespace(password_hash=None)
|
||||||
|
payload = LoginRequest(
|
||||||
|
email="passwordless@example.test", password="not-a-user-password"
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
|
def test_account_without_active_membership_uses_same_generic_failure(self) -> None:
|
||||||
|
account = SimpleNamespace(
|
||||||
|
id="account-1",
|
||||||
|
password_hash="pbkdf2_sha256$260000$account-salt$account-digest",
|
||||||
|
)
|
||||||
|
session = self._session_with_account(account)
|
||||||
|
session.query.return_value.join.return_value.filter.return_value.order_by.return_value.first.return_value = None
|
||||||
|
payload = LoginRequest(email="known@example.test", password="correct-password")
|
||||||
|
|
||||||
|
with patch.object(auth, "verify_password", return_value=True):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_login_user(session, payload) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
219
tests/test_login_throttle.py
Normal file
219
tests/test_login_throttle.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from redis.exceptions import RedisError
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1 import auth
|
||||||
|
from govoplan_access.backend.security.login_throttle import (
|
||||||
|
AttemptBucket,
|
||||||
|
InMemoryLoginAttemptStore,
|
||||||
|
LoginThrottle,
|
||||||
|
LoginThrottleDecision,
|
||||||
|
ResilientLoginAttemptStore,
|
||||||
|
)
|
||||||
|
from govoplan_core.api.v1.schemas import LoginRequest
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottleTests(unittest.TestCase):
|
||||||
|
def test_identity_and_client_buckets_enforce_independent_limits(self) -> None:
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
identity_limit=2,
|
||||||
|
client_limit=3,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"normalized_email": "person@example.test",
|
||||||
|
"tenant_slug": "tenant-a",
|
||||||
|
"client_address": "192.0.2.4",
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertTrue(throttle.record_failure(**context).allowed)
|
||||||
|
self.assertFalse(throttle.record_failure(**context).allowed)
|
||||||
|
|
||||||
|
other_identity = {**context, "normalized_email": "other@example.test"}
|
||||||
|
self.assertFalse(throttle.record_failure(**other_identity).allowed)
|
||||||
|
|
||||||
|
def test_success_clears_identity_bucket_without_erasing_client_failures(self) -> None:
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
identity_limit=2,
|
||||||
|
client_limit=3,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
"normalized_email": "person@example.test",
|
||||||
|
"tenant_slug": "tenant-a",
|
||||||
|
"client_address": "192.0.2.8",
|
||||||
|
}
|
||||||
|
self.assertTrue(throttle.record_failure(**context).allowed)
|
||||||
|
|
||||||
|
throttle.record_success(
|
||||||
|
normalized_email=context["normalized_email"],
|
||||||
|
tenant_slug=context["tenant_slug"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(throttle.check(**context).allowed)
|
||||||
|
other_identity = {**context, "normalized_email": "other@example.test"}
|
||||||
|
self.assertTrue(throttle.record_failure(**other_identity).allowed)
|
||||||
|
self.assertFalse(throttle.record_failure(**other_identity).allowed)
|
||||||
|
|
||||||
|
def test_rotating_untrusted_tenant_slug_does_not_bypass_identity_limit(self) -> None:
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
InMemoryLoginAttemptStore(),
|
||||||
|
identity_limit=2,
|
||||||
|
client_limit=100,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
throttle.record_failure(
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
tenant_slug="tenant-a",
|
||||||
|
client_address="192.0.2.1",
|
||||||
|
).allowed
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
throttle.record_failure(
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
tenant_slug="made-up-tenant",
|
||||||
|
client_address="198.51.100.9",
|
||||||
|
).allowed
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bucket_keys_do_not_contain_identity_or_client_data(self) -> None:
|
||||||
|
store = MagicMock()
|
||||||
|
store.increment.return_value = AttemptBucket(count=1, retry_after_seconds=60)
|
||||||
|
throttle = LoginThrottle(
|
||||||
|
store,
|
||||||
|
identity_limit=10,
|
||||||
|
client_limit=100,
|
||||||
|
window_seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
throttle.record_failure(
|
||||||
|
normalized_email="private.person@example.test",
|
||||||
|
tenant_slug="private-tenant",
|
||||||
|
client_address="192.0.2.9",
|
||||||
|
)
|
||||||
|
|
||||||
|
keys = [call.args[0] for call in store.increment.call_args_list]
|
||||||
|
self.assertEqual(len(keys), 2)
|
||||||
|
for key in keys:
|
||||||
|
self.assertNotIn("private", key)
|
||||||
|
self.assertNotIn("example", key)
|
||||||
|
self.assertNotIn("192.0.2.9", key)
|
||||||
|
|
||||||
|
def test_redis_failure_uses_local_store_during_retry_window(self) -> None:
|
||||||
|
primary = MagicMock()
|
||||||
|
primary.read.side_effect = RedisError("not available")
|
||||||
|
fallback = InMemoryLoginAttemptStore()
|
||||||
|
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
|
||||||
|
|
||||||
|
with self.assertLogs(
|
||||||
|
"govoplan_access.backend.security.login_throttle",
|
||||||
|
level="WARNING",
|
||||||
|
) as captured:
|
||||||
|
self.assertEqual(store.read("bucket"), AttemptBucket())
|
||||||
|
result = store.increment("bucket", window_seconds=60)
|
||||||
|
|
||||||
|
self.assertEqual(result.count, 1)
|
||||||
|
self.assertEqual(primary.read.call_count, 1)
|
||||||
|
primary.increment.assert_not_called()
|
||||||
|
self.assertIn("process-local fallback", captured.output[0])
|
||||||
|
|
||||||
|
def test_redis_recovery_does_not_erase_failures_counted_by_the_fallback(self) -> None:
|
||||||
|
primary = MagicMock()
|
||||||
|
primary.read.side_effect = [RedisError("not available"), AttemptBucket(count=1, retry_after_seconds=30)]
|
||||||
|
primary.increment.return_value = AttemptBucket(count=2, retry_after_seconds=30)
|
||||||
|
fallback = InMemoryLoginAttemptStore()
|
||||||
|
store = ResilientLoginAttemptStore(primary, fallback, retry_seconds=60)
|
||||||
|
|
||||||
|
with self.assertLogs("govoplan_access.backend.security.login_throttle", level="WARNING"):
|
||||||
|
store.read("bucket")
|
||||||
|
store.increment("bucket", window_seconds=60)
|
||||||
|
store.increment("bucket", window_seconds=60)
|
||||||
|
|
||||||
|
store._primary_unavailable_until = 0 # Simulate the next Redis retry window.
|
||||||
|
recovered = store.read("bucket")
|
||||||
|
incremented = store.increment("bucket", window_seconds=60)
|
||||||
|
|
||||||
|
self.assertEqual(recovered.count, 2)
|
||||||
|
self.assertEqual(incremented.count, 3)
|
||||||
|
|
||||||
|
def test_in_memory_store_is_bounded(self) -> None:
|
||||||
|
store = InMemoryLoginAttemptStore(max_entries=2)
|
||||||
|
for key in ("one", "two", "three"):
|
||||||
|
store.increment(key, window_seconds=60)
|
||||||
|
|
||||||
|
active = sum(store.read(key).count > 0 for key in ("one", "two", "three"))
|
||||||
|
self.assertEqual(active, 2)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottleRouteTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.payload = LoginRequest(
|
||||||
|
email="Person@Example.Test",
|
||||||
|
password="attempt",
|
||||||
|
tenant_slug="tenant-a",
|
||||||
|
)
|
||||||
|
self.request = SimpleNamespace(client=SimpleNamespace(host="192.0.2.10"))
|
||||||
|
|
||||||
|
def test_throttled_identity_gets_same_generic_failure_detail(self) -> None:
|
||||||
|
throttle = MagicMock()
|
||||||
|
throttle.check.return_value = LoginThrottleDecision(False, 42)
|
||||||
|
|
||||||
|
with patch.object(auth, "_configured_login_throttle", return_value=throttle):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 429)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
self.assertEqual(raised.exception.headers, {"Retry-After": "42"})
|
||||||
|
|
||||||
|
def test_failed_login_is_counted_and_threshold_response_stays_generic(self) -> None:
|
||||||
|
throttle = MagicMock()
|
||||||
|
throttle.check.return_value = LoginThrottleDecision(True)
|
||||||
|
throttle.record_failure.return_value = LoginThrottleDecision(False, 60)
|
||||||
|
generic_failure = HTTPException(status_code=401, detail="Invalid login")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(auth, "_configured_login_throttle", return_value=throttle),
|
||||||
|
patch.object(auth, "_resolve_login_user", side_effect=generic_failure),
|
||||||
|
):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(raised.exception.status_code, 429)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid login")
|
||||||
|
throttle.record_failure.assert_called_once_with(
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
tenant_slug="tenant-a",
|
||||||
|
client_address="192.0.2.10",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_success_clears_only_the_identity_bucket(self) -> None:
|
||||||
|
throttle = MagicMock()
|
||||||
|
throttle.check.return_value = LoginThrottleDecision(True)
|
||||||
|
resolved = (object(), object(), object())
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(auth, "_configured_login_throttle", return_value=throttle),
|
||||||
|
patch.object(auth, "_resolve_login_user", return_value=resolved),
|
||||||
|
):
|
||||||
|
result = auth._resolve_throttled_login_user(MagicMock(), self.payload, self.request) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
self.assertEqual(result, resolved)
|
||||||
|
throttle.record_failure.assert_not_called()
|
||||||
|
throttle.record_success.assert_called_once_with(
|
||||||
|
normalized_email="person@example.test",
|
||||||
|
tenant_slug="tenant-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -15,8 +15,8 @@ class OptionalTenancyContractTests(unittest.TestCase):
|
|||||||
|
|
||||||
dependencies = tuple(project["dependencies"])
|
dependencies = tuple(project["dependencies"])
|
||||||
|
|
||||||
self.assertIn("govoplan-core>=0.1.6", dependencies)
|
self.assertIn("govoplan-core>=0.1.11", dependencies)
|
||||||
self.assertNotIn("govoplan-tenancy>=0.1.6", dependencies)
|
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
|
||||||
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))
|
||||||
|
|
||||||
def test_tenancy_is_declared_as_optional_module_integration(self) -> None:
|
def test_tenancy_is_declared_as_optional_module_integration(self) -> None:
|
||||||
|
|||||||
95
tests/test_people_search.py
Normal file
95
tests/test_people_search.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, User
|
||||||
|
from govoplan_access.backend.manifest import manifest
|
||||||
|
from govoplan_access.backend.people_search import AccessPeopleSearchProvider
|
||||||
|
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH, PeopleSearchError, PeopleSearchProvider
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AccessPeopleSearchTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(bind=self.engine)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session = self.Session()
|
||||||
|
self.provider = AccessPeopleSearchProvider()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(bind=self.engine)
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _add_user(
|
||||||
|
self,
|
||||||
|
suffix: str,
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
display_name: str | None = None,
|
||||||
|
user_active: bool = True,
|
||||||
|
account_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
email = f"{suffix}@example.test"
|
||||||
|
account = Account(
|
||||||
|
id=f"account-{suffix}",
|
||||||
|
email=email,
|
||||||
|
normalized_email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
is_active=account_active,
|
||||||
|
)
|
||||||
|
self.session.add_all([
|
||||||
|
account,
|
||||||
|
User(
|
||||||
|
id=f"user-{suffix}",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
is_active=user_active,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
def test_search_is_tenant_bounded_and_excludes_inactive_records(self) -> None:
|
||||||
|
self._add_user("ada", display_name="Ada Lovelace")
|
||||||
|
self._add_user("other", tenant_id="tenant-2", display_name="Ada Other Tenant")
|
||||||
|
self._add_user("inactive-user", display_name="Ada Inactive User", user_active=False)
|
||||||
|
self._add_user("inactive-account", display_name="Ada Inactive Account", account_active=False)
|
||||||
|
|
||||||
|
groups = self.provider.search_people(
|
||||||
|
self.session,
|
||||||
|
SimpleNamespace(tenant_id="tenant-1"),
|
||||||
|
query="ada",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([group.key for group in groups], ["accounts"])
|
||||||
|
self.assertEqual([item.reference_id for item in groups[0].candidates], ["account-ada"])
|
||||||
|
self.assertEqual(groups[0].candidates[0].email, "ada@example.test")
|
||||||
|
self.assertNotIn("tenant_id", groups[0].candidates[0].metadata)
|
||||||
|
|
||||||
|
def test_search_escapes_like_wildcards_and_requires_tenant_context(self) -> None:
|
||||||
|
self._add_user("ada", display_name="Ada Lovelace")
|
||||||
|
|
||||||
|
groups = self.provider.search_people(
|
||||||
|
self.session,
|
||||||
|
SimpleNamespace(tenant_id="tenant-1"),
|
||||||
|
query="%",
|
||||||
|
)
|
||||||
|
self.assertEqual(groups[0].candidates, ())
|
||||||
|
with self.assertRaises(PeopleSearchError):
|
||||||
|
self.provider.search_people(self.session, SimpleNamespace(tenant_id=None), query="ada")
|
||||||
|
|
||||||
|
def test_manifest_exposes_the_shared_interface(self) -> None:
|
||||||
|
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, manifest.capability_factories)
|
||||||
|
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
|
||||||
|
self.assertIsInstance(self.provider, PeopleSearchProvider)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
35
tests/test_permission_catalog_contract.py
Normal file
35
tests/test_permission_catalog_contract.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_access.backend.permissions import catalog as access_catalog
|
||||||
|
from govoplan_core.security import permissions as core_permissions
|
||||||
|
from govoplan_core.security.scope_aliases import LEGACY_SCOPE_ALIASES
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionCatalogContractTests(unittest.TestCase):
|
||||||
|
def test_access_reuses_core_legacy_scope_aliases(self) -> None:
|
||||||
|
self.assertIs(access_catalog.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
|
||||||
|
self.assertIs(core_permissions.LEGACY_SCOPE_ALIASES, LEGACY_SCOPE_ALIASES)
|
||||||
|
|
||||||
|
def test_legacy_alias_grants_match_in_core_and_access(self) -> None:
|
||||||
|
for legacy_scope, aliases in LEGACY_SCOPE_ALIASES.items():
|
||||||
|
for alias in aliases:
|
||||||
|
self.assertTrue(core_permissions.scope_grants(legacy_scope, alias))
|
||||||
|
self.assertTrue(access_catalog.scope_grants(legacy_scope, alias))
|
||||||
|
|
||||||
|
def test_access_legacy_catalog_includes_non_access_platform_scopes(self) -> None:
|
||||||
|
scopes = {permission.scope for permission in access_catalog.permission_catalog(include_legacy=True)}
|
||||||
|
self.assertIn("campaign:queue", scopes)
|
||||||
|
self.assertIn("files:upload", scopes)
|
||||||
|
self.assertIn("mail_servers:test", scopes)
|
||||||
|
|
||||||
|
def test_expand_scopes_preserves_explicit_canonical_scope_with_legacy_alias(self) -> None:
|
||||||
|
scopes = access_catalog.expand_scopes(("files:file:read",))
|
||||||
|
|
||||||
|
self.assertIn("files:file:read", scopes)
|
||||||
|
self.assertIn("files:read", scopes)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
153
tests/test_reference_options.py
Normal file
153
tests/test_reference_options.py
Normal 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()
|
||||||
202
tests/test_service_accounts.py
Normal file
202
tests/test_service_accounts.py
Normal 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()
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -1,47 +1,24 @@
|
|||||||
import type { ApiSettings, DeltaDeletedItem } from "@govoplan/core-webui";
|
import type {
|
||||||
import { apiFetch } from "@govoplan/core-webui";
|
AccessDecisionProvenanceItem as CoreAccessDecisionProvenanceItem,
|
||||||
|
ApiSettings,
|
||||||
export type PermissionItem = {
|
DeltaDeletedItem,
|
||||||
scope: string;
|
PrivacyRetentionPolicy,
|
||||||
label: string;
|
ResourceAccessExplanationOptions,
|
||||||
description: string;
|
ResourceAccessExplanationResponse as CoreResourceAccessExplanationResponse,
|
||||||
category: string;
|
TenantAdminItem
|
||||||
level: "tenant" | "system";
|
} from "@govoplan/core-webui";
|
||||||
system_template_id?: string | null;
|
import { apiFetch, apiGetList, apiPath, apiQuery, fetchResourceAccessExplanation as fetchCoreResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
system_required?: boolean;
|
export { fetchAdminOverview, fetchPermissionCatalog, fetchTenants } from "@govoplan/core-webui";
|
||||||
};
|
export type {
|
||||||
|
AdminOverview,
|
||||||
export type AdminOverview = {
|
PrivacyRetentionLimitPermissionPatch,
|
||||||
active_tenant_id: string;
|
PrivacyRetentionLimitPermissions,
|
||||||
active_tenant_name: string;
|
PrivacyRetentionPolicy,
|
||||||
tenant_count?: number | null;
|
PrivacyRetentionPolicyFieldKey,
|
||||||
system_account_count?: number | null;
|
PrivacyRetentionPolicyPatch,
|
||||||
system_group_template_count?: number | null;
|
PermissionItem,
|
||||||
system_role_template_count?: number | null;
|
TenantAdminItem
|
||||||
user_count: number;
|
} from "@govoplan/core-webui";
|
||||||
active_user_count: number;
|
|
||||||
group_count: number;
|
|
||||||
role_count: number;
|
|
||||||
active_api_key_count: number;
|
|
||||||
capabilities: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TenantAdminItem = {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
default_locale: string;
|
|
||||||
settings: Record<string, unknown>;
|
|
||||||
allow_custom_groups?: boolean | null;
|
|
||||||
allow_custom_roles?: boolean | null;
|
|
||||||
allow_api_keys?: boolean | null;
|
|
||||||
effective_governance: Record<string, boolean>;
|
|
||||||
is_active: boolean;
|
|
||||||
counts: Record<string, number>;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TenantOwnerCandidate = {
|
export type TenantOwnerCandidate = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -132,12 +109,7 @@ export type AccessScopeExplanationItem = {
|
|||||||
sources: AccessRoleSourceItem[];
|
sources: AccessRoleSourceItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccessDecisionProvenanceItem = {
|
export type AccessDecisionProvenanceItem = Omit<CoreAccessDecisionProvenanceItem, "details"> & {
|
||||||
kind: string;
|
|
||||||
id?: string | null;
|
|
||||||
label?: string | null;
|
|
||||||
tenant_id?: string | null;
|
|
||||||
source?: string | null;
|
|
||||||
details: Record<string, unknown>;
|
details: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,13 +139,7 @@ export type UserAccessExplanationResponse = {
|
|||||||
function_facts: FunctionFactExplanationItem[];
|
function_facts: FunctionFactExplanationItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ResourceAccessExplanationResponse = {
|
export type ResourceAccessExplanationResponse = CoreResourceAccessExplanationResponse<UserAdminItem, AccessDecisionProvenanceItem>;
|
||||||
user: UserAdminItem;
|
|
||||||
resource_type: string;
|
|
||||||
resource_id: string;
|
|
||||||
action: string;
|
|
||||||
provenance: AccessDecisionProvenanceItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SystemAccountItem = {
|
export type SystemAccountItem = {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -195,28 +161,6 @@ export type SystemMembershipDraft = {
|
|||||||
is_last_active_owner?: boolean;
|
is_last_active_owner?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PrivacyRetentionPolicyFieldKey =
|
|
||||||
| "store_raw_campaign_json"
|
|
||||||
| "raw_campaign_json_retention_days"
|
|
||||||
| "generated_eml_retention_days"
|
|
||||||
| "stored_report_detail_retention_days"
|
|
||||||
| "mock_mailbox_retention_days"
|
|
||||||
| "audit_detail_retention_days"
|
|
||||||
| "audit_detail_level";
|
|
||||||
|
|
||||||
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
|
|
||||||
|
|
||||||
export type PrivacyRetentionPolicy = {
|
|
||||||
store_raw_campaign_json: boolean;
|
|
||||||
raw_campaign_json_retention_days?: number | null;
|
|
||||||
generated_eml_retention_days?: number | null;
|
|
||||||
stored_report_detail_retention_days?: number | null;
|
|
||||||
mock_mailbox_retention_days?: number | null;
|
|
||||||
audit_detail_retention_days?: number | null;
|
|
||||||
audit_detail_level: "full" | "redacted" | "minimal";
|
|
||||||
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SystemSettingsItem = {
|
export type SystemSettingsItem = {
|
||||||
default_locale: string;
|
default_locale: string;
|
||||||
allow_tenant_custom_groups: boolean;
|
allow_tenant_custom_groups: boolean;
|
||||||
@@ -317,36 +261,18 @@ export type TenantSettingsDeltaResponse = {
|
|||||||
} & DeltaResponseFields;
|
} & DeltaResponseFields;
|
||||||
|
|
||||||
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
function deltaSuffix(options: { since?: string | null; limit?: number } = {}): string {
|
||||||
const params = new URLSearchParams();
|
return apiQuery(options);
|
||||||
if (options.since) params.set("since", options.since);
|
|
||||||
if (options.limit) params.set("limit", String(options.limit));
|
|
||||||
return params.toString() ? `?${params.toString()}` : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
|
|
||||||
return apiFetch(settings, "/api/v1/admin/overview");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
|
|
||||||
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
|
|
||||||
return response.permissions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
|
|
||||||
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
|
|
||||||
return response.tenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
|
export function fetchTenantsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<TenantListDeltaResponse> {
|
||||||
const suffix = deltaSuffix(options);
|
const suffix = deltaSuffix(options);
|
||||||
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
|
return apiFetch(settings, `/api/v1/admin/tenants/delta${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
|
||||||
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
|
return apiGetList<TenantOwnerCandidate, "accounts">(settings, "/api/v1/admin/tenants/owner-candidates", "accounts");
|
||||||
return response.accounts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTenant(settings: ApiSettings, payload: {
|
export function createTenant(settings: ApiSettings, payload: {
|
||||||
@@ -427,21 +353,13 @@ export function fetchUserAccessExplanation(settings: ApiSettings, userId: string
|
|||||||
|
|
||||||
export function fetchResourceAccessExplanation(
|
export function fetchResourceAccessExplanation(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
options: { userId: string; resourceType: string; resourceId: string; action: string; tenantId?: string | null }
|
options: ResourceAccessExplanationOptions
|
||||||
): Promise<ResourceAccessExplanationResponse> {
|
): Promise<ResourceAccessExplanationResponse> {
|
||||||
const params = new URLSearchParams({
|
return fetchCoreResourceAccessExplanation<UserAdminItem, AccessDecisionProvenanceItem>(settings, options);
|
||||||
user_id: options.userId,
|
|
||||||
resource_type: options.resourceType,
|
|
||||||
resource_id: options.resourceId,
|
|
||||||
action: options.action
|
|
||||||
});
|
|
||||||
if (options.tenantId) params.set("tenant_id", options.tenantId);
|
|
||||||
return apiFetch(settings, `/api/v1/admin/access/resource-explanation?${params.toString()}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
|
||||||
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
|
return apiGetList<GroupSummary, "groups">(settings, "/api/v1/admin/groups", "groups");
|
||||||
return response.groups;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
|
export function fetchGroupsDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GroupListDeltaResponse> {
|
||||||
@@ -471,8 +389,7 @@ export function updateGroup(settings: ApiSettings, groupId: string, payload: Par
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
|
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/roles", "roles");
|
||||||
return response.roles;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
export function fetchRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
||||||
@@ -528,8 +445,7 @@ export function deleteExternalFunctionRoleMapping(settings: ApiSettings, mapping
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
|
||||||
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
|
return apiGetList<RoleSummary, "roles">(settings, "/api/v1/admin/system/roles", "roles");
|
||||||
return response.roles;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
export function fetchSystemRolesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<RoleListDeltaResponse> {
|
||||||
@@ -587,20 +503,15 @@ export function updateSystemAccountRoles(settings: ApiSettings, accountId: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
|
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
|
||||||
const params = new URLSearchParams();
|
return apiGetList<ApiKeyAdminItem, "api_keys">(settings, "/api/v1/admin/api-keys", "api_keys", { include_revoked: includeRevoked ? true : undefined });
|
||||||
if (includeRevoked) params.set("include_revoked", "true");
|
|
||||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
||||||
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
|
|
||||||
return response.api_keys;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
|
export function fetchApiKeysDelta(settings: ApiSettings, includeRevoked = false, options: { since?: string | null; limit?: number } = {}): Promise<ApiKeyListDeltaResponse> {
|
||||||
const params = new URLSearchParams();
|
return apiFetch(settings, apiPath("/api/v1/admin/api-keys/delta", {
|
||||||
if (includeRevoked) params.set("include_revoked", "true");
|
include_revoked: includeRevoked ? true : undefined,
|
||||||
if (options.since) params.set("since", options.since);
|
since: options.since,
|
||||||
if (options.limit) params.set("limit", String(options.limit));
|
limit: options.limit
|
||||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
}));
|
||||||
return apiFetch(settings, `/api/v1/admin/api-keys/delta${suffix}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApiKey(settings: ApiSettings, payload: {
|
export function createApiKey(settings: ApiSettings, payload: {
|
||||||
@@ -653,9 +564,7 @@ export function updateSystemSettings(settings: ApiSettings, payload: SystemSetti
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
|
||||||
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
|
return apiGetList<GovernanceTemplateItem, "templates">(settings, "/api/v1/admin/system/governance-templates", "templates", { kind });
|
||||||
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
|
|
||||||
return response.templates;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {
|
export function fetchGovernanceTemplatesDelta(settings: ApiSettings, options: { since?: string | null; limit?: number } = {}): Promise<GovernanceTemplateListDeltaResponse> {
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import type {
|
|||||||
AdminSectionsUiCapability,
|
AdminSectionsUiCapability,
|
||||||
ApiSettings,
|
ApiSettings,
|
||||||
AuthInfo,
|
AuthInfo,
|
||||||
|
AuthUpdate,
|
||||||
FilesConnectorsUiCapability,
|
FilesConnectorsUiCapability,
|
||||||
MailProfilesUiCapability,
|
MailProfilesUiCapability,
|
||||||
OrganizationFunctionPickerUiCapability
|
OrganizationFunctionPickerUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { fetchMe } from "@govoplan/core-webui";
|
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
import { 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";
|
||||||
@@ -24,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",
|
||||||
@@ -42,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",
|
||||||
@@ -49,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,
|
||||||
@@ -63,20 +131,29 @@ export default function AdminPage({
|
|||||||
}: {
|
}: {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
onAuthChange: (auth: AuthInfo | null, accessToken?: string) => void;
|
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||||
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>();
|
||||||
@@ -94,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");
|
||||||
@@ -112,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");
|
||||||
@@ -131,28 +224,25 @@ export default function AdminPage({
|
|||||||
}, [requestedSection, available, fallbackSection]);
|
}, [requestedSection, available, fallbackSection]);
|
||||||
|
|
||||||
async function refreshAuth() {
|
async function refreshAuth() {
|
||||||
onAuthChange(await fetchMe(settings));
|
onAuthChange(await fetchShellAuth(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refreshAuth().catch(() => undefined);
|
|
||||||
}, [settings.accessToken, settings.apiBaseUrl]);
|
|
||||||
|
|
||||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||||
return (
|
return (
|
||||||
|
<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),
|
||||||
@@ -160,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),
|
||||||
@@ -173,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} />
|
||||||
)}
|
)}
|
||||||
@@ -248,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} />}
|
||||||
@@ -273,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(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
147
webui/src/features/admin/CredentialEnvelopesPanel.tsx
Normal file
147
webui/src/features/admin/CredentialEnvelopesPanel.tsx
Normal 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}.`;
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
|||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime } from "@govoplan/core-webui";
|
||||||
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
@@ -257,12 +257,10 @@ export default function ExternalFunctionRoleMappingsPanel({
|
|||||||
sticky: "end",
|
sticky: "end",
|
||||||
resizable: false,
|
resizable: false,
|
||||||
align: "right",
|
align: "right",
|
||||||
render: (row) => (
|
render: (row) => <TableActionGroup actions={[
|
||||||
<div className="admin-icon-actions">
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id }), icon: <Pencil />, disabled: !canWrite, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.function_id })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite} />
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id }), icon: <Trash2 />, variant: "danger", disabled: !canWrite, onClick: () => setDeleting(row) }
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.function_id })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite} />
|
]} />
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[auth, canWrite, functionPicker, roleById, settings]
|
[auth, canWrite, functionPicker, roleById, settings]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
const emptyDraft = { slug: "", name: "", description: "", isActive: true, memberIds: [] as string[], roleIds: [] as string[] };
|
||||||
@@ -127,15 +127,15 @@ export default function GroupsPanel({ settings, auth, canDefine, canManageMember
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
const columns = useMemo<DataGridColumn<GroupSummary>[]>(() => [
|
||||||
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
{ id: "group", header: "i18n:govoplan-access.group.171a0606", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
|
||||||
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
{ id: "members", header: "i18n:govoplan-access.members.1cb449c1", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.member_count },
|
||||||
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
{ id: "roles", header: "i18n:govoplan-access.inherited_roles.8def9f05", width: 260, minWidth: 180, maxWidth: 520, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canDefine || canManageMembers || canAssignRoles)} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !(canDefine || canManageMembers || canAssignRoles), onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canDefine || !row.is_active || Boolean(row.system_required)} />
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canDefine || Boolean(row.system_required), onClick: () => setDeactivating(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[canAssignRoles, canDefine, canManageMembers]);
|
[canAssignRoles, canDefine, canManageMembers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage } from "@govoplan/core-webui";
|
||||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
@@ -78,10 +78,12 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
|||||||
setDraft(emptyDraft);
|
setDraft(emptyDraft);
|
||||||
setSavedDraftKey(draftKey(emptyDraft));
|
setSavedDraftKey(draftKey(emptyDraft));
|
||||||
}
|
}
|
||||||
function togglePermission(scope: string, checked: boolean) {
|
function setPermissionGroup(scopes: string[], selected: string[]) {
|
||||||
const next = new Set(draft.permissions);
|
const groupScopes = new Set(scopes);
|
||||||
if (checked) next.add(scope);else next.delete(scope);
|
setDraft({
|
||||||
setDraft({ ...draft, permissions: Array.from(next) });
|
...draft,
|
||||||
|
permissions: [...draft.permissions.filter((scope) => !groupScopes.has(scope)), ...selected]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(): Promise<boolean> {
|
async function save(): Promise<boolean> {
|
||||||
@@ -118,15 +120,15 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
const columns = useMemo<DataGridColumn<RoleSummary>[]>(() => [
|
||||||
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <span className="admin-managed-badge">i18n:govoplan-access.system.bc0792d8{row.system_required ? "i18n:govoplan-access.required.7c65879a" : ""}</span>}</div></div> },
|
{ id: "role", header: "i18n:govoplan-access.role.c3f104d1", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sticky: "start", sortable: true, filterable: true, value: (row) => `${row.name} ${row.slug}`, render: (row) => <div><strong>{row.name}</strong><div className="muted small-note">{row.slug} {row.system_template_id && <StatusBadge status={row.system_required ? "warning" : "inactive"} label={`i18n:govoplan-access.system.bc0792d8${row.system_required ? " · i18n:govoplan-access.required.7c65879a" : ""}`} />}</div></div> },
|
||||||
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
{ id: "permissions", header: "i18n:govoplan-access.permissions.d06d5557", width: 170, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.effective_permission_count, render: (row) => hasTenantWildcard(row.permissions) ? `${row.effective_permission_count} (tenant:*)` : String(row.effective_permission_count) },
|
||||||
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
{ id: "assignments", header: "i18n:govoplan-access.assignments.057d58c7", width: 220, minWidth: 170, maxWidth: 420, resizable: true, fill: true, sortable: true, value: (row) => row.user_assignments + row.group_assignments, render: (row) => `${row.user_assignments} users / ${row.group_assignments} groups` },
|
||||||
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
{ id: "type", header: "i18n:govoplan-access.type.3deb7456", width: 140, resizable: false, sortable: true, filterable: true, value: (row) => row.is_builtin ? "built-in" : row.system_template_id ? "system-managed" : "custom", render: (row) => <StatusBadge status={row.is_builtin ? "built" : "active"} label={row.is_builtin ? "i18n:govoplan-access.built_in.20f409cc" : row.system_template_id ? "i18n:govoplan-access.system.bc0792d8" : "i18n:govoplan-access.custom.081ae3fd"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id)} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canDefine || row.is_builtin || Boolean(row.system_template_id) || row.user_assignments + row.group_assignments > 0} />
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !row.is_builtin && !row.system_template_id, disabled: !canDefine || row.user_assignments + row.group_assignments > 0, onClick: () => setDeleting(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[canDefine, permissions]);
|
[canDefine, permissions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -142,7 +144,10 @@ export default function RolesPanel({ settings, auth, canDefine, onAuthRefresh }:
|
|||||||
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
<FormField label="i18n:govoplan-access.description.55f8ebc8"><textarea rows={3} value={draft.description} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
||||||
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
{editing !== "new" && <FormField label="i18n:govoplan-access.assignable.a88debc5"><select value={draft.isAssignable ? "yes" : "no"} onChange={(event) => setDraft({ ...draft, isAssignable: event.target.value === "yes" })}><option value="yes">i18n:govoplan-access.yes.5397e058</option><option value="no">i18n:govoplan-access.no.816c52fd</option></select></FormField>}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => <fieldset key={category} className="admin-permission-group"><legend>{category}</legend>{items.map((permission) => <label key={permission.scope} className="admin-selection-item"><input type="checkbox" checked={draft.permissions.includes(permission.scope)} onChange={(event) => togglePermission(permission.scope, event.target.checked)} /><span><strong>{permission.label}</strong><small>{permission.description}<code>{permission.scope}</code></small></span></label>)}</fieldset>)}</div>
|
<div className="admin-permission-groups">{permissionGroups.map(([category, items]) => {
|
||||||
|
const scopes = items.map((permission) => permission.scope);
|
||||||
|
return <fieldset key={category} className="admin-permission-group"><legend>{category}</legend><AdminSelectionList options={items.map((permission) => ({ id: permission.scope, label: permission.label, description: <>{permission.description}<code>{permission.scope}</code></> }))} selected={draft.permissions.filter((scope) => scopes.includes(scope))} onChange={(selected) => setPermissionGroup(scopes, selected)} /></fieldset>;
|
||||||
|
})}</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
<Dialog open={Boolean(viewing)} title="i18n:govoplan-access.role_details.a16b5d9f" onClose={() => setViewing(null)} className="admin-dialog admin-dialog-wide" footer={<Button onClick={() => setViewing(null)}>i18n:govoplan-access.close.bbfa773e</Button>}>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
|||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, joinLabels, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
@@ -223,11 +223,11 @@ export default function SystemRolesPanel({
|
|||||||
align: "right",
|
align: "right",
|
||||||
render: (row) => {
|
render: (row) => {
|
||||||
const protectedOwner = row.slug === "system_owner";
|
const protectedOwner = row.slug === "system_owner";
|
||||||
return <div className="admin-icon-actions">
|
return <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canWrite || protectedOwner} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, applicable: !protectedOwner, disabled: !canWrite, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setDeleting(row)} disabled={!canWrite || protectedOwner || row.user_assignments > 0} />
|
{ id: "delete", label: i18nMessage("i18n:govoplan-access.delete_value.4d18989e", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: !protectedOwner, disabled: !canWrite || row.user_assignments > 0, onClick: () => setDeleting(row) }
|
||||||
</div>;
|
]} />;
|
||||||
}
|
}
|
||||||
}],
|
}],
|
||||||
[canWrite]);
|
[canWrite]);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
type SystemMembershipDraft,
|
type SystemMembershipDraft,
|
||||||
type TenantAdminItem
|
type TenantAdminItem
|
||||||
} from "../../api/admin";
|
} from "../../api/admin";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels, i18nMessage, mergeDeltaRows, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const emptyDraft = {
|
const emptyDraft = {
|
||||||
email: "",
|
email: "",
|
||||||
@@ -83,8 +83,17 @@ export default function SystemUsersPanel({
|
|||||||
let hasMore = false;
|
let hasMore = false;
|
||||||
do {
|
do {
|
||||||
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
|
const response = await fetchSystemAccountsDelta(settings, { since: nextWatermark });
|
||||||
nextAccounts = response.full ? response.accounts : mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
|
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||||
nextRoles = response.full ? response.roles : mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
|
nextAccounts = response.full
|
||||||
|
? continuingFullSnapshot
|
||||||
|
? mergeDeltaRows(nextAccounts, response.accounts, [], (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts })
|
||||||
|
: response.accounts
|
||||||
|
: mergeDeltaRows(nextAccounts, response.accounts, response.deleted, (account) => account.account_id, { deletedResourceType: "access_system_account", sort: sortSystemAccounts });
|
||||||
|
nextRoles = response.full
|
||||||
|
? continuingFullSnapshot
|
||||||
|
? mergeDeltaRows(nextRoles, response.roles, [], (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles })
|
||||||
|
: response.roles
|
||||||
|
: mergeDeltaRows(nextRoles, response.roles, response.deleted, (role) => role.id, { deletedResourceType: "access_system_role", sort: sortSystemRoles });
|
||||||
nextWatermark = response.watermark ?? null;
|
nextWatermark = response.watermark ?? null;
|
||||||
hasMore = response.has_more;
|
hasMore = response.has_more;
|
||||||
} while (hasMore);
|
} while (hasMore);
|
||||||
@@ -140,16 +149,13 @@ export default function SystemUsersPanel({
|
|||||||
setSavedDraftKey(draftKey(emptyDraft));
|
setSavedDraftKey(draftKey(emptyDraft));
|
||||||
}
|
}
|
||||||
|
|
||||||
function membership(tenantId: string) {
|
function setMembershipSelection(tenantIds: string[]) {
|
||||||
return draft.memberships.find((item) => item.tenant_id === tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setMembership(tenantId: string, enabled: boolean) {
|
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
...current,
|
...current,
|
||||||
memberships: enabled ?
|
memberships: tenantIds.map((tenantId) =>
|
||||||
[...current.memberships.filter((item) => item.tenant_id !== tenantId), { tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }] :
|
current.memberships.find((item) => item.tenant_id === tenantId) ??
|
||||||
current.memberships.filter((item) => item.tenant_id !== tenantId)
|
{ tenant_id: tenantId, is_active: true, role_ids: [], group_ids: [] }
|
||||||
|
)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,11 +212,11 @@ export default function SystemUsersPanel({
|
|||||||
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
{ id: "roles", header: "i18n:govoplan-access.system_roles.a9461aa6", width: 220, minWidth: 170, maxWidth: 420, resizable: true, sortable: true, filterable: true, value: (row) => joinLabels(row.roles) },
|
||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canAssignRoles || canManageMemberships)} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canAssignRoles || canManageMemberships), onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.memberships.some((membership) => membership.is_last_active_owner)} />
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.memberships.some((membership) => membership.is_last_active_owner), onClick: () => setDeactivating(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
[canAssignRoles, canManageMemberships, canSuspend, canUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -244,7 +250,7 @@ export default function SystemUsersPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="admin-assignment-grid">
|
<div className="admin-assignment-grid">
|
||||||
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
<div><span className="form-label">i18n:govoplan-access.system_roles.a9461aa6</span><AdminSelectionList options={roles.map((role) => ({ id: role.id, label: role.name, description: role.description, disabled: !canAssignRoles }))} selected={draft.roleIds} onChange={(roleIds) => setDraft({ ...draft, roleIds })} /></div>
|
||||||
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><div className="admin-selection-list">{tenants.map((tenant) => <label className="admin-selection-item" key={tenant.id}><input type="checkbox" checked={Boolean(membership(tenant.id))} disabled={!canManageMemberships || Boolean(membership(tenant.id)?.is_last_active_owner)} onChange={(event) => setMembership(tenant.id, event.target.checked)} /><span><strong>{tenant.name}</strong><small>{tenant.slug}</small></span></label>)}</div></div>
|
<div><span className="form-label">i18n:govoplan-access.tenant_memberships.451de736</span><AdminSelectionList options={tenants.map((tenant) => ({ id: tenant.id, label: tenant.name, description: tenant.slug, disabled: !canManageMemberships || Boolean(draft.memberships.find((item) => item.tenant_id === tenant.id)?.is_last_active_owner) }))} selected={draft.memberships.map((item) => item.tenant_id)} onChange={setMembershipSelection} /></div>
|
||||||
</div>
|
</div>
|
||||||
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
{editing && editing !== "new" && editing.memberships.some((membership) => membership.is_last_active_owner) && <p className="admin-protection-note">i18n:govoplan-access.this_account_is_the_last_active_operational_owne.5087839f</p>}
|
||||||
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
<p className="muted small-note">i18n:govoplan-access.removing_a_tenant_checkbox_suspends_that_members.7c6df77d</p>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from "@govoplan/core-webui";
|
|||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
|
import { fetchTenantSettingsDelta, updateTenantSettings, type TenantSettingsDeltaSections, type TenantSettingsItem } from "../../api/admin";
|
||||||
import { AdminPageLayout, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminPageLayout, AdminSelectionList, adminErrorMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const DELTA_KEY = "access:tenant-settings";
|
const DELTA_KEY = "access:tenant-settings";
|
||||||
|
|
||||||
@@ -94,10 +94,8 @@ export default function TenantSettingsPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLanguage(code: string, checked: boolean) {
|
function setEnabledLanguages(selected: string[]) {
|
||||||
const enabled = new Set(draft.enabled_language_codes);
|
const enabled = new Set(selected);
|
||||||
if (checked) enabled.add(code);
|
|
||||||
else enabled.delete(code);
|
|
||||||
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
|
const nextEnabled = draft.system_enabled_language_codes.filter((item) => enabled.has(item));
|
||||||
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
const defaultLocale = nextEnabled.includes(draft.default_locale) ? draft.default_locale : (nextEnabled[0] ?? draft.default_locale);
|
||||||
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
setDraft({ ...draft, enabled_language_codes: nextEnabled, default_locale: defaultLocale });
|
||||||
@@ -122,21 +120,14 @@ export default function TenantSettingsPanel({
|
|||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="settings-list">
|
<AdminSelectionList
|
||||||
{draft.system_enabled_language_codes.map((code) => {
|
options={draft.system_enabled_language_codes.map((code) => {
|
||||||
const language = draft.available_languages.find((item) => item.code === code);
|
const language = draft.available_languages.find((item) => item.code === code);
|
||||||
return (
|
return { id: code, label: code.toUpperCase(), description: languageOptionLabel(language ?? { code, label: code.toUpperCase() }), disabled: !canWrite || busy || code === draft.default_locale };
|
||||||
<label className="admin-inline-check" key={code}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={draft.enabled_language_codes.includes(code)}
|
|
||||||
disabled={!canWrite || busy || code === draft.default_locale}
|
|
||||||
onChange={(event) => toggleLanguage(code, event.target.checked)} />
|
|
||||||
<span><strong>{code.toUpperCase()}</strong> {languageOptionLabel(language ?? { code, label: code.toUpperCase() })}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
</div>
|
selected={draft.enabled_language_codes}
|
||||||
|
onChange={setEnabledLanguages}
|
||||||
|
/>
|
||||||
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
|
<p className="muted small-note">i18n:govoplan-access.tenant_languages_help</p>
|
||||||
<dl className="detail-list">
|
<dl className="detail-list">
|
||||||
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
<div><dt>i18n:govoplan-access.tenant.3ca93c78</dt><dd>{draft.name || "-"}</dd></div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
type OverrideValue = "inherit" | "allow" | "deny";
|
type OverrideValue = "inherit" | "allow" | "deny";
|
||||||
@@ -225,11 +225,11 @@ export default function TenantsPanel({
|
|||||||
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
{ id: "files", header: "i18n:govoplan-access.files.6ce6c512", width: 90, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => row.counts.files ?? 0 },
|
||||||
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
{ id: "locale", header: "i18n:govoplan-access.locale.8970f0e6", width: 120, minWidth: 90, maxWidth: 220, resizable: true, fill: true, sortable: true, filterable: true, value: (row) => row.default_locale },
|
||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active ? "active" : "inactive"} /> },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 150, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.name }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!canUpdate} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.name }), icon: <Pencil />, disabled: !canUpdate, onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name })} icon={<Trash2 />} variant="danger" onClick={() => setConfirmSuspend(row)} disabled={!canSuspend || !row.is_active || row.id === activeTenantId} />
|
{ id: "suspend", label: i18nMessage("i18n:govoplan-access.suspend_value.03a74b32", { value0: row.name }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.id === activeTenantId, onClick: () => setConfirmSuspend(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[activeTenantId, canSuspend, canUpdate]);
|
[activeTenantId, canSuspend, canUpdate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { Dialog } from "@govoplan/core-webui";
|
|||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { PasswordField } from "@govoplan/core-webui";
|
import { PasswordField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { AdminIconButton, AdminPageLayout, AdminSelectionList, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
import { AdminIconButton, AdminPageLayout, AdminSelectionList, TableActionGroup, adminErrorMessage, formatAdminDateTime as formatDateTime, joinLabels } from "@govoplan/core-webui";
|
||||||
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { hasTenantWildcard, i18nMessage, useDeltaWatermarks, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { loadDeltaRows } from "./utils/deltaRows";
|
import { loadDeltaRows } from "./utils/deltaRows";
|
||||||
|
|
||||||
@@ -186,12 +187,12 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
|
{ id: "scope_count", header: "i18n:govoplan-access.permissions.d06d5557", width: 110, resizable: false, sortable: true, filterable: true, filterType: "integer", value: (row) => hasTenantWildcard(row.effective_scopes) ? 999 : row.effective_scopes.length, render: (row) => hasTenantWildcard(row.effective_scopes) ? "i18n:govoplan-access.all.6a720856" : String(row.effective_scopes.length) },
|
||||||
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
{ id: "status", header: "i18n:govoplan-access.status.bae7d5be", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.is_active && row.account_is_active ? "active" : "inactive", render: (row) => <StatusBadge status={row.is_active && row.account_is_active ? "active" : "inactive"} /> },
|
||||||
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
{ id: "last_login", header: "i18n:govoplan-access.last_login.43dab84f", width: 180, minWidth: 150, resizable: true, sortable: true, value: (row) => row.last_login_at || "", render: (row) => formatDateTime(row.last_login_at) },
|
||||||
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <div className="admin-icon-actions">
|
{ id: "actions", header: "i18n:govoplan-access.actions.c3cd636a", width: 190, sticky: "end", resizable: false, align: "right", render: (row) => <TableActionGroup actions={[
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email })} icon={<Search />} onClick={() => setViewing(row)} />
|
{ id: "inspect", label: i18nMessage("i18n:govoplan-access.inspect_value.9d5d1071", { value0: row.email }), icon: <Search />, onClick: () => setViewing(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email })} icon={<KeyRound />} onClick={() => void openAccessExplanation(row)} />
|
{ id: "explain", label: i18nMessage("i18n:govoplan-access.explain_access_for_value.3af96e47", { value0: row.email }), icon: <KeyRound />, onClick: () => void openAccessExplanation(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email })} icon={<Pencil />} onClick={() => openEdit(row)} disabled={!(canUpdate || canSuspend || canManageGroups || canAssignRoles)} />
|
{ id: "edit", label: i18nMessage("i18n:govoplan-access.edit_value.fad75899", { value0: row.email }), icon: <Pencil />, disabled: !(canUpdate || canSuspend || canManageGroups || canAssignRoles), onClick: () => openEdit(row) },
|
||||||
<AdminIconButton label={i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email })} icon={<Trash2 />} variant="danger" onClick={() => setDeactivating(row)} disabled={!canSuspend || !row.is_active || row.is_last_active_owner} />
|
{ id: "deactivate", label: i18nMessage("i18n:govoplan-access.deactivate_value.a276a667", { value0: row.email }), icon: <Trash2 />, variant: "danger", applicable: row.is_active, disabled: !canSuspend || row.is_last_active_owner, onClick: () => setDeactivating(row) }
|
||||||
</div> }],
|
]} /> }],
|
||||||
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
[canAssignRoles, canManageGroups, canSuspend, canUpdate, settings]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +217,7 @@ export default function UsersPanel({ settings, auth, canCreate, canUpdate, canSu
|
|||||||
}
|
}
|
||||||
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
<FormField label="i18n:govoplan-access.membership_status.b77fc732"><select value={draft.isActive ? "active" : "inactive"} disabled={Boolean(editing && editing !== "new" && (!canSuspend || editing.is_last_active_owner))} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-access.active.a733b809</option><option value="inactive">i18n:govoplan-access.inactive.09af574c</option></select></FormField>
|
||||||
</div>
|
</div>
|
||||||
{editing === "new" && <label className="admin-inline-check"><input type="checkbox" checked={draft.passwordResetRequired} onChange={(event) => setDraft({ ...draft, passwordResetRequired: event.target.checked })} /> i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3</label>}
|
{editing === "new" && <ToggleSwitch label="i18n:govoplan-access.require_password_change_when_account_settings_ar.69bce7a3" checked={draft.passwordResetRequired} onChange={(passwordResetRequired) => setDraft({ ...draft, passwordResetRequired })} />}
|
||||||
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
{editing && editing !== "new" && editing.is_last_active_owner && <p className="admin-protection-note">i18n:govoplan-access.this_membership_is_the_tenant_s_last_active_oper.072b247f</p>}
|
||||||
<div className="admin-assignment-grid">
|
<div className="admin-assignment-grid">
|
||||||
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
<div><span className="form-label">i18n:govoplan-access.groups.ae9629f4</span><AdminSelectionList options={groups.filter((group) => group.is_active).map((group) => ({ id: group.id, label: group.name, description: group.description, disabled: !canManageGroups }))} selected={draft.groupIds} onChange={(groupIds) => setDraft({ ...draft, groupIds })} emptyText="i18n:govoplan-access.no_groups_exist_yet.9cd029f6" /></div>
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ export async function loadDeltaRows<TItem, TResponse extends AdminDeltaResponse>
|
|||||||
do {
|
do {
|
||||||
const response = await fetchDelta(nextWatermark);
|
const response = await fetchDelta(nextWatermark);
|
||||||
const rows = rowsFromResponse(response);
|
const rows = rowsFromResponse(response);
|
||||||
merged = response.full ? rows : mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
const continuingFullSnapshot = response.full && nextWatermark?.startsWith("full:");
|
||||||
|
merged = response.full
|
||||||
|
? continuingFullSnapshot
|
||||||
|
? mergeDeltaRows(merged, rows, [], getKey, { deletedResourceType, sort })
|
||||||
|
: rows
|
||||||
|
: mergeDeltaRows(merged, rows, response.deleted, getKey, { deletedResourceType, sort });
|
||||||
nextWatermark = response.watermark ?? null;
|
nextWatermark = response.watermark ?? null;
|
||||||
hasMore = response.has_more;
|
hasMore = response.has_more;
|
||||||
} while (hasMore);
|
} while (hasMore);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}",
|
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Explain access for {value0}",
|
||||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||||
|
"i18n:govoplan-access.available.7c62a142": "Available",
|
||||||
"i18n:govoplan-access.file_connections.1e362326": "File connections",
|
"i18n:govoplan-access.file_connections.1e362326": "File connections",
|
||||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
|
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Files module unavailable",
|
||||||
"i18n:govoplan-access.files.6ce6c512": "Files",
|
"i18n:govoplan-access.files.6ce6c512": "Files",
|
||||||
@@ -119,6 +120,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
|
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "The source module and function ID identify an accepted external function fact. Access grants the selected tenant role only while IDM reports an active accepted assignment for that fact.",
|
||||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
|
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Function mapping updated.",
|
||||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
|
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Function mappings",
|
||||||
|
"i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Map accepted function facts to tenant roles.",
|
||||||
"i18n:govoplan-access.general.9239ee2c": "General",
|
"i18n:govoplan-access.general.9239ee2c": "General",
|
||||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||||
@@ -456,6 +458,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren",
|
"i18n:govoplan-access.explain_access_for_value.3af96e47": "Zugriff fuer {value0} erklaeren",
|
||||||
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
"i18n:govoplan-access.explicit_allow_is_unavailable_for.8d05fd4a": "Explicit allow is unavailable for",
|
||||||
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
"i18n:govoplan-access.explicitly_deny.17ad945a": "Explicitly deny",
|
||||||
|
"i18n:govoplan-access.available.7c62a142": "Verfuegbar",
|
||||||
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
|
"i18n:govoplan-access.file_connections.1e362326": "Dateiverbindungen",
|
||||||
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
|
"i18n:govoplan-access.files_module_unavailable.0ee90db1": "Dateimodul nicht verfügbar",
|
||||||
"i18n:govoplan-access.files.6ce6c512": "Dateien",
|
"i18n:govoplan-access.files.6ce6c512": "Dateien",
|
||||||
@@ -467,6 +470,7 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
|
"i18n:govoplan-access.function_role_mapping_help.0cf9996a": "Quellmodul und Funktions-ID identifizieren einen akzeptierten externen Funktionsfakt. Access gewährt die ausgewählte Mandantenrolle nur, solange IDM eine aktive akzeptierte Zuweisung für diesen Fakt meldet.",
|
||||||
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
|
"i18n:govoplan-access.function_role_mapping_updated.76020443": "Funktionszuordnung aktualisiert.",
|
||||||
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
|
"i18n:govoplan-access.function_role_mappings.2b64e9c3": "Funktionszuordnungen",
|
||||||
|
"i18n:govoplan-access.map_accepted_function_facts_to_tenant_roles_.7581e5cf": "Akzeptierte Funktionsfakten Mandantenrollen zuordnen.",
|
||||||
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
|
"i18n:govoplan-access.general.9239ee2c": "Allgemein",
|
||||||
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
"i18n:govoplan-access.global_account_details.0a0cf240": "Global account details",
|
||||||
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
"i18n:govoplan-access.global_account_value_created.5100e467": "Global account {value0} created.",
|
||||||
|
|||||||
@@ -2,4 +2,6 @@ export { default } from "./module";
|
|||||||
export * from "./module";
|
export * from "./module";
|
||||||
export * from "./api/admin";
|
export * from "./api/admin";
|
||||||
export { default as AdminPage } from "./features/admin/AdminPage";
|
export { default as AdminPage } from "./features/admin/AdminPage";
|
||||||
|
export { ResourceAccessExplanation } from "@govoplan/core-webui";
|
||||||
|
export type { ResourceAccessExplanationOptions, ResourceAccessExplanationProps, ResourceAccessExplanationUser } from "@govoplan/core-webui";
|
||||||
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user