Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 984a015704 | |||
| 3df4fc5bff | |||
| 1b57df7753 | |||
| bf1ecc54b3 | |||
| fdfcfbb440 |
@@ -84,6 +84,14 @@ instead of importing access ORM models or backend dependency internals.
|
||||
The detailed module boundary and serialization fields are documented in
|
||||
[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
|
||||
|
||||
The repository root and `webui/` directory both expose the package
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy import case, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
|
||||
@@ -12,12 +12,34 @@ from govoplan_core.core.access import AccessAdministration
|
||||
class SqlAccessAdministration(AccessAdministration):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
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 {
|
||||
"users": db.query(User).filter(User.tenant_id == tenant_id).count(),
|
||||
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
|
||||
"users": int(users),
|
||||
"active_users": int(active_users),
|
||||
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(),
|
||||
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
|
||||
"api_keys": int(api_keys),
|
||||
"active_api_keys": int(active_api_keys),
|
||||
}
|
||||
|
||||
def system_account_count(self, session: object) -> int:
|
||||
|
||||
@@ -24,7 +24,6 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
)
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
collect_direct_user_roles,
|
||||
collect_system_roles,
|
||||
collect_user_groups,
|
||||
collect_user_scopes,
|
||||
)
|
||||
@@ -48,7 +47,11 @@ from govoplan_access.backend.db.models import (
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.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:
|
||||
@@ -358,44 +361,249 @@ def _user_item(
|
||||
)
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
memberships = (
|
||||
def _system_membership_rows(
|
||||
session: Session,
|
||||
account_ids: list[str],
|
||||
) -> list[tuple[User, Tenant]]:
|
||||
return (
|
||||
session.query(User, Tenant)
|
||||
.join(Tenant, Tenant.id == User.tenant_id)
|
||||
.filter(User.account_id == account.id)
|
||||
.order_by(Tenant.name.asc())
|
||||
.filter(User.account_id.in_(account_ids))
|
||||
.order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
|
||||
.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(
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name=account.display_name,
|
||||
is_active=account.is_active,
|
||||
memberships=[
|
||||
{
|
||||
"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 collect_direct_user_roles(session, user)],
|
||||
"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
|
||||
),
|
||||
}
|
||||
_system_membership_item(
|
||||
user,
|
||||
tenant,
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _system_account_items(
|
||||
session: Session,
|
||||
accounts: list[Account],
|
||||
) -> list[SystemAccountItem]:
|
||||
if not accounts:
|
||||
return []
|
||||
account_ids = [account.id for account in accounts]
|
||||
accounts_by_id = {account.id: account for account in accounts}
|
||||
membership_rows = _system_membership_rows(session, account_ids)
|
||||
memberships_by_account = _memberships_by_account(membership_rows)
|
||||
user_ids = [user.id for user, _tenant in membership_rows]
|
||||
roles_by_user = _system_direct_roles_by_user(session, user_ids)
|
||||
groups_by_user = _system_groups_by_user(session, user_ids)
|
||||
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
|
||||
owner_ids_by_tenant = _system_owner_ids_by_tenant(
|
||||
membership_rows,
|
||||
accounts_by_id=accounts_by_id,
|
||||
direct_roles_by_user=roles_by_user,
|
||||
group_roles_by_user=group_roles_by_user,
|
||||
)
|
||||
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
|
||||
session,
|
||||
account_ids,
|
||||
)
|
||||
return [
|
||||
_system_account_response_item(
|
||||
session,
|
||||
account,
|
||||
memberships=memberships_by_account[account.id],
|
||||
roles_by_user=roles_by_user,
|
||||
groups_by_user=groups_by_user,
|
||||
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||
system_roles=system_roles_by_account[account.id],
|
||||
system_role_counts=system_role_counts,
|
||||
)
|
||||
for account in accounts
|
||||
]
|
||||
|
||||
|
||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||
return _system_account_items(session, [account])[0]
|
||||
|
||||
|
||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||
if accounts_by_user_id is not None:
|
||||
account = accounts_by_user_id.get(item.user_id)
|
||||
|
||||
@@ -193,7 +193,7 @@ class OrganizationUnitItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationUnitListResponse(BaseModel):
|
||||
class OrganizationUnitListResponse(PagedListResponse):
|
||||
organization_units: list[OrganizationUnitItem]
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ class FunctionAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionListResponse(BaseModel):
|
||||
class FunctionListResponse(PagedListResponse):
|
||||
functions: list[FunctionAdminItem]
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
||||
class ExternalFunctionRoleMappingListResponse(PagedListResponse):
|
||||
mappings: list[ExternalFunctionRoleMappingItem]
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ class FunctionAssignmentAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentListResponse(BaseModel):
|
||||
class FunctionAssignmentListResponse(PagedListResponse):
|
||||
assignments: list[FunctionAssignmentAdminItem]
|
||||
|
||||
|
||||
@@ -368,7 +368,7 @@ class FunctionDelegationAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionDelegationListResponse(BaseModel):
|
||||
class FunctionDelegationListResponse(PagedListResponse):
|
||||
delegations: list[FunctionDelegationAdminItem]
|
||||
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ from govoplan_access.backend.api.v1.admin_common import (
|
||||
_set_system_memberships,
|
||||
_system_role_assignment_counts,
|
||||
_system_account_item,
|
||||
_system_account_items,
|
||||
_tenant_role_assignment_counts,
|
||||
_user_item,
|
||||
)
|
||||
@@ -484,14 +485,42 @@ def _validate_parent_organization_unit(
|
||||
raise AdminValidationError("Parent organization unit does not belong to the tenant.")
|
||||
|
||||
|
||||
def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
||||
role_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id == item.id)
|
||||
.order_by(FunctionRoleAssignment.created_at.asc())
|
||||
def _function_role_ids_by_function_id(
|
||||
session: Session,
|
||||
function_ids: Iterable[str],
|
||||
) -> dict[str, list[str]]:
|
||||
requested = tuple(dict.fromkeys(function_ids))
|
||||
result: dict[str, list[str]] = {function_id: [] for function_id in requested}
|
||||
if not requested:
|
||||
return result
|
||||
rows = (
|
||||
session.query(
|
||||
FunctionRoleAssignment.function_id,
|
||||
FunctionRoleAssignment.role_id,
|
||||
)
|
||||
.filter(FunctionRoleAssignment.function_id.in_(requested))
|
||||
.order_by(
|
||||
FunctionRoleAssignment.function_id.asc(),
|
||||
FunctionRoleAssignment.created_at.asc(),
|
||||
)
|
||||
.all()
|
||||
]
|
||||
)
|
||||
for function_id, role_id in rows:
|
||||
result.setdefault(function_id, []).append(role_id)
|
||||
return result
|
||||
|
||||
|
||||
def _function_item(
|
||||
session: Session,
|
||||
item: Function,
|
||||
*,
|
||||
role_ids_by_function_id: dict[str, list[str]] | None = None,
|
||||
) -> FunctionAdminItem:
|
||||
if role_ids_by_function_id is None:
|
||||
role_ids_by_function_id = _function_role_ids_by_function_id(
|
||||
session,
|
||||
(item.id,),
|
||||
)
|
||||
return FunctionAdminItem(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
@@ -499,7 +528,7 @@ def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
||||
slug=item.slug,
|
||||
name=item.name,
|
||||
description=item.description,
|
||||
role_ids=role_ids,
|
||||
role_ids=role_ids_by_function_id.get(item.id, []),
|
||||
delegable=item.delegable,
|
||||
act_in_place_allowed=item.act_in_place_allowed,
|
||||
is_active=item.is_active,
|
||||
@@ -1266,17 +1295,26 @@ def deactivate_identity(
|
||||
@router.get("/organization-units", response_model=OrganizationUnitListResponse)
|
||||
def list_organization_units(
|
||||
tenant_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
items = (
|
||||
query = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant.id)
|
||||
.order_by(OrganizationUnit.name.asc())
|
||||
.all()
|
||||
)
|
||||
return OrganizationUnitListResponse(organization_units=[_organization_unit_item(item) for item in items])
|
||||
items, pagination = _page_query(
|
||||
query,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return OrganizationUnitListResponse(
|
||||
organization_units=[_organization_unit_item(item) for item in items],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -1394,6 +1432,8 @@ def deactivate_organization_unit(
|
||||
def list_functions(
|
||||
tenant_id: str | None = None,
|
||||
organization_unit_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
@@ -1401,8 +1441,26 @@ def list_functions(
|
||||
query = session.query(Function).filter(Function.tenant_id == tenant.id)
|
||||
if organization_unit_id:
|
||||
query = query.filter(Function.organization_unit_id == organization_unit_id)
|
||||
functions = query.order_by(Function.name.asc()).all()
|
||||
return FunctionListResponse(functions=[_function_item(session, item) for item in functions])
|
||||
functions, pagination = _page_query(
|
||||
query.order_by(Function.name.asc()),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
role_ids_by_function_id = _function_role_ids_by_function_id(
|
||||
session,
|
||||
(item.id for item in functions),
|
||||
)
|
||||
return FunctionListResponse(
|
||||
functions=[
|
||||
_function_item(
|
||||
session,
|
||||
item,
|
||||
role_ids_by_function_id=role_ids_by_function_id,
|
||||
)
|
||||
for item in functions
|
||||
],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -1535,6 +1593,8 @@ def list_external_function_role_mappings(
|
||||
tenant_id: str | None = None,
|
||||
source_module: str | None = None,
|
||||
function_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
@@ -1544,23 +1604,57 @@ def list_external_function_role_mappings(
|
||||
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
|
||||
if function_id:
|
||||
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
|
||||
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all()
|
||||
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items])
|
||||
items, pagination = _page_query(
|
||||
query.order_by(
|
||||
ExternalFunctionRoleAssignment.source_module.asc(),
|
||||
ExternalFunctionRoleAssignment.function_id.asc(),
|
||||
),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return ExternalFunctionRoleMappingListResponse(
|
||||
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse:
|
||||
items = (
|
||||
def _full_external_function_role_mappings_delta_response(
|
||||
session: Session,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
cursor: tuple[int, int] | None = None,
|
||||
limit: int = 500,
|
||||
) -> ExternalFunctionRoleMappingListDeltaResponse:
|
||||
snapshot_sequence = (
|
||||
cursor[1]
|
||||
if cursor is not None
|
||||
else max_sequence_id(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
module_id=ACCESS_MODULE_ID,
|
||||
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
|
||||
)
|
||||
)
|
||||
page = cursor[0] if cursor is not None else 1
|
||||
query = (
|
||||
session.query(ExternalFunctionRoleAssignment)
|
||||
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
||||
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
|
||||
.all()
|
||||
)
|
||||
items, pagination, watermark, has_more = _full_delta_page(
|
||||
query,
|
||||
page=page,
|
||||
page_size=limit,
|
||||
scope="external-function-role-mappings",
|
||||
snapshot_sequence=snapshot_sequence,
|
||||
)
|
||||
return ExternalFunctionRoleMappingListDeltaResponse(
|
||||
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)),
|
||||
has_more=False,
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=True,
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@@ -1620,8 +1714,17 @@ def list_external_function_role_mappings_delta(
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
if since is None:
|
||||
return _full_external_function_role_mappings_delta_response(session, tenant)
|
||||
full_cursor = _decode_full_delta_cursor(
|
||||
since,
|
||||
scope="external-function-role-mappings",
|
||||
)
|
||||
if since is None or full_cursor is not None:
|
||||
return _full_external_function_role_mappings_delta_response(
|
||||
session,
|
||||
tenant,
|
||||
cursor=full_cursor,
|
||||
limit=limit,
|
||||
)
|
||||
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
|
||||
|
||||
|
||||
@@ -1735,6 +1838,8 @@ def list_function_assignments(
|
||||
tenant_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
function_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")),
|
||||
):
|
||||
@@ -1744,8 +1849,15 @@ def list_function_assignments(
|
||||
query = query.filter(FunctionAssignment.account_id == account_id)
|
||||
if function_id:
|
||||
query = query.filter(FunctionAssignment.function_id == function_id)
|
||||
assignments = query.order_by(FunctionAssignment.created_at.desc()).all()
|
||||
return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments])
|
||||
assignments, pagination = _page_query(
|
||||
query.order_by(FunctionAssignment.created_at.desc()),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return FunctionAssignmentListResponse(
|
||||
assignments=[_function_assignment_item(item) for item in assignments],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -1888,6 +2000,8 @@ def deactivate_function_assignment(
|
||||
def list_function_delegations(
|
||||
tenant_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")),
|
||||
):
|
||||
@@ -1895,8 +2009,15 @@ def list_function_delegations(
|
||||
query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id)
|
||||
if account_id:
|
||||
query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id))
|
||||
delegations = query.order_by(FunctionDelegation.created_at.desc()).all()
|
||||
return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations])
|
||||
delegations, pagination = _page_query(
|
||||
query.order_by(FunctionDelegation.created_at.desc()),
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return FunctionDelegationListResponse(
|
||||
delegations=[_function_delegation_item(item) for item in delegations],
|
||||
**pagination,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED)
|
||||
@@ -2981,6 +3102,25 @@ def _system_role_summaries_for_response(session: Session, roles: list[Role]) ->
|
||||
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
|
||||
|
||||
|
||||
def _system_roles_for_account_catalog(session: Session) -> list[Role]:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.filter(Role.tenant_id.is_(None))
|
||||
.order_by(Role.name.asc(), Role.id.asc())
|
||||
.limit(1001)
|
||||
.all()
|
||||
)
|
||||
if len(roles) > 1000:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail=(
|
||||
"The system role catalog exceeds 1000 entries. "
|
||||
"Use the paginated system roles endpoint."
|
||||
),
|
||||
)
|
||||
return roles
|
||||
|
||||
|
||||
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
|
||||
ensure_default_roles(session, None)
|
||||
session.commit()
|
||||
@@ -3178,13 +3318,13 @@ _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_
|
||||
def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
|
||||
ensure_default_roles(session, None)
|
||||
session.commit()
|
||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
||||
system_roles = _system_roles_for_account_catalog(session)
|
||||
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS)
|
||||
page = cursor[0] if cursor is not None else 1
|
||||
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc())
|
||||
accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
|
||||
return SystemAccountListDeltaResponse(
|
||||
accounts=[_system_account_item(session, account) for account in accounts],
|
||||
accounts=_system_account_items(session, accounts),
|
||||
roles=_system_role_summaries_for_response(session, system_roles),
|
||||
deleted=[],
|
||||
watermark=watermark,
|
||||
@@ -3223,7 +3363,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int)
|
||||
)
|
||||
]
|
||||
return SystemAccountListDeltaResponse(
|
||||
accounts=[_system_account_item(session, account) for account in visible_accounts.values()],
|
||||
accounts=_system_account_items(session, list(visible_accounts.values())),
|
||||
roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more),
|
||||
@@ -3255,11 +3395,11 @@ def list_system_accounts(
|
||||
):
|
||||
ensure_default_roles(session, None)
|
||||
session.commit()
|
||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
||||
system_roles = _system_roles_for_account_catalog(session)
|
||||
query = session.query(Account).order_by(Account.email.asc())
|
||||
accounts, pagination = _page_query(query, page=page, page_size=page_size)
|
||||
return SystemAccountListResponse(
|
||||
accounts=[_system_account_item(session, account) for account in accounts],
|
||||
accounts=_system_account_items(session, accounts),
|
||||
roles=_system_role_summaries_for_response(session, system_roles),
|
||||
**pagination,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -15,6 +15,10 @@ from govoplan_core.core.access import (
|
||||
PrincipalRef,
|
||||
PrincipalResolver,
|
||||
)
|
||||
from govoplan_core.core.automation import (
|
||||
AutomationPrincipalRequest,
|
||||
AutomationPrincipalResolution,
|
||||
)
|
||||
from govoplan_core.core.identity import IdentityDirectory
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
@@ -245,11 +249,19 @@ def _resolve_api_key_principal_context(
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
# API keys remain supported for CLI/automation. Their permissions are the
|
||||
# intersection of the key grant and the owner's current tenant roles.
|
||||
api_key = authenticate_api_key(session, token)
|
||||
api_key = authenticate_api_key(
|
||||
session,
|
||||
token,
|
||||
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
|
||||
)
|
||||
if api_key is None:
|
||||
return None
|
||||
user = session.get(User, api_key.user_id)
|
||||
account = session.get(Account, user.account_id) if user else None
|
||||
activity_touch_pending = session.is_modified(
|
||||
api_key,
|
||||
include_collections=False,
|
||||
)
|
||||
user = api_key.user
|
||||
account = user.account if user else None
|
||||
tenant = session.get(Tenant, api_key.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
@@ -273,7 +285,6 @@ def _resolve_api_key_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
api_key=api_key,
|
||||
@@ -287,6 +298,8 @@ def _resolve_api_key_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
)
|
||||
if activity_touch_pending:
|
||||
session.commit()
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
||||
|
||||
|
||||
@@ -322,11 +335,19 @@ def _resolve_session_principal_context(
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> ResolvedPrincipalContext | None:
|
||||
auth_session = authenticate_session_token(session, token)
|
||||
auth_session = authenticate_session_token(
|
||||
session,
|
||||
token,
|
||||
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
|
||||
)
|
||||
if auth_session is None:
|
||||
return None
|
||||
user = session.get(User, auth_session.user_id)
|
||||
account = session.get(Account, auth_session.account_id)
|
||||
activity_touch_pending = session.is_modified(
|
||||
auth_session,
|
||||
include_collections=False,
|
||||
)
|
||||
user = auth_session.user
|
||||
account = auth_session.account
|
||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||
if (
|
||||
not user or not account or not tenant
|
||||
@@ -353,7 +374,6 @@ def _resolve_session_principal_context(
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
scopes = authorization_context.scopes
|
||||
session.commit()
|
||||
principal = _build_principal_ref(
|
||||
session,
|
||||
auth_session=auth_session,
|
||||
@@ -368,6 +388,8 @@ def _resolve_session_principal_context(
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=True,
|
||||
)
|
||||
if activity_touch_pending:
|
||||
session.commit()
|
||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
||||
|
||||
|
||||
@@ -558,6 +580,149 @@ class AccessApiPrincipalProvider:
|
||||
)
|
||||
|
||||
|
||||
class AccessAutomationPrincipalProvider:
|
||||
"""Rebuild a trigger owner against current tenant authorization."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> None:
|
||||
self._idm_directory = idm_directory
|
||||
self._identity_directory = identity_directory
|
||||
self._organization_directory = organization_directory
|
||||
|
||||
def resolve_automation_principal(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
) -> AutomationPrincipalResolution:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError(
|
||||
"Access automation principal resolution requires a "
|
||||
"SQLAlchemy session"
|
||||
)
|
||||
account = session.get(Account, request.account_id)
|
||||
user = session.get(User, request.membership_id)
|
||||
tenant = session.get(Tenant, request.tenant_id)
|
||||
if (
|
||||
account is None
|
||||
or user is None
|
||||
or tenant is None
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=False,
|
||||
reason=(
|
||||
"The automation owner is inactive, missing, or no longer "
|
||||
"belongs to the tenant."
|
||||
),
|
||||
missing_scopes=tuple(sorted(set(request.grant_scopes))),
|
||||
provenance={
|
||||
"authorization_ref": request.authorization_ref,
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"membership_id": request.membership_id,
|
||||
"status": "inactive_or_inconsistent",
|
||||
},
|
||||
)
|
||||
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=request.tenant_id,
|
||||
idm_directory=self._idm_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=False,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
current_scopes = authorization_context.scopes
|
||||
granted_scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
required
|
||||
for required in request.grant_scopes
|
||||
if scopes_grant_compatible(current_scopes, required)
|
||||
}
|
||||
)
|
||||
)
|
||||
missing_scopes = tuple(
|
||||
sorted(set(request.grant_scopes) - set(granted_scopes))
|
||||
)
|
||||
if missing_scopes:
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=False,
|
||||
reason=(
|
||||
"The automation owner no longer has every scope granted "
|
||||
"to this trigger."
|
||||
),
|
||||
granted_scopes=granted_scopes,
|
||||
missing_scopes=missing_scopes,
|
||||
provenance={
|
||||
"authorization_ref": request.authorization_ref,
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"membership_id": request.membership_id,
|
||||
"status": "authorization_reduced",
|
||||
},
|
||||
)
|
||||
|
||||
principal_ref = _build_principal_ref(
|
||||
session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=request.tenant_id,
|
||||
scopes=list(granted_scopes),
|
||||
auth_method="service_account",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=self._identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=False,
|
||||
)
|
||||
principal_ref = replace(
|
||||
principal_ref,
|
||||
service_account_id=request.authorization_ref,
|
||||
acting_for_account_id=account.id,
|
||||
)
|
||||
api_principal = _api_principal_from_ref(
|
||||
session,
|
||||
principal_ref,
|
||||
permission_evaluator=LegacyPermissionEvaluator(),
|
||||
)
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=True,
|
||||
principal=api_principal,
|
||||
granted_scopes=granted_scopes,
|
||||
provenance={
|
||||
"authorization_ref": request.authorization_ref,
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"membership_id": request.membership_id,
|
||||
"role_ids": sorted(principal_ref.role_ids),
|
||||
"group_ids": sorted(principal_ref.group_ids),
|
||||
"function_assignment_ids": sorted(
|
||||
principal_ref.function_assignment_ids
|
||||
),
|
||||
"delegation_ids": sorted(principal_ref.delegation_ids),
|
||||
"status": "current_authorization_resolved",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_api_principal(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
|
||||
@@ -15,6 +15,7 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
@@ -41,6 +42,7 @@ from govoplan_core.core.modules import (
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
|
||||
@@ -71,6 +73,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_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_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: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"),
|
||||
@@ -91,6 +95,9 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "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: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: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"),
|
||||
@@ -125,6 +132,8 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:system_role:assign",
|
||||
"access:system_setting:read",
|
||||
"access:system_setting:write",
|
||||
"access:system_credential:read",
|
||||
"access:system_credential:write",
|
||||
"access:governance:read",
|
||||
"access:governance:write",
|
||||
),
|
||||
@@ -185,6 +194,8 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:api_key:revoke",
|
||||
"access:setting:read",
|
||||
"access:setting:write",
|
||||
"access:credential:read",
|
||||
"access:credential:write",
|
||||
"access:policy:read",
|
||||
"access:policy:write",
|
||||
),
|
||||
@@ -244,6 +255,10 @@ ADMIN_READ_SCOPES = (
|
||||
"access:account:read",
|
||||
"access:governance:read",
|
||||
"access:function:read",
|
||||
"views:definition:read",
|
||||
"views:assignment:read",
|
||||
"views:system_definition:read",
|
||||
"views:system_assignment:read",
|
||||
)
|
||||
|
||||
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
@@ -479,6 +494,18 @@ def _api_principal_provider(context: ModuleContext) -> object:
|
||||
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:
|
||||
del context
|
||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||
@@ -612,6 +639,10 @@ manifest = ModuleManifest(
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
||||
ModuleInterfaceProvider(
|
||||
name="auth.automation_principal",
|
||||
version="0.1.0",
|
||||
),
|
||||
),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
@@ -650,9 +681,28 @@ manifest = ModuleManifest(
|
||||
package_name="@govoplan/access-webui",
|
||||
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),),
|
||||
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_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider,
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER: (
|
||||
_automation_principal_provider
|
||||
),
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.db.models import ApiKey, User
|
||||
@@ -60,15 +60,34 @@ def create_api_key(
|
||||
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)
|
||||
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()
|
||||
for candidate in candidates:
|
||||
expires_at = ensure_aware_utc(candidate.expires_at)
|
||||
if expires_at and expires_at < now:
|
||||
continue
|
||||
if verify_api_key(secret, candidate.key_hash):
|
||||
last_used_at = ensure_aware_utc(candidate.last_used_at)
|
||||
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
|
||||
@@ -78,4 +97,3 @@ def authenticate_api_key(session: Session, secret: str) -> ApiKey | None:
|
||||
def has_scope(api_key: ApiKey, required_scope: str) -> bool:
|
||||
scopes = set(api_key.scopes or [])
|
||||
return "*" in scopes or required_scope in scopes
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
||||
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.db.models import (
|
||||
@@ -104,15 +104,37 @@ def create_auth_session(
|
||||
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)
|
||||
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:
|
||||
return None
|
||||
now = utc_now()
|
||||
expires_at = ensure_aware_utc(model.expires_at)
|
||||
if expires_at is None or expires_at < now:
|
||||
return None
|
||||
last_seen_at = ensure_aware_utc(model.last_seen_at)
|
||||
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
|
||||
|
||||
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()
|
||||
162
tests/test_automation_principal.py
Normal file
162
tests/test_automation_principal.py
Normal file
@@ -0,0 +1,162 @@
|
||||
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, 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",
|
||||
),
|
||||
)
|
||||
|
||||
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.assertEqual(
|
||||
"dataflow-trigger:1",
|
||||
result.principal.principal.service_account_id,
|
||||
)
|
||||
self.assertNotIn(
|
||||
"system:settings:write",
|
||||
result.principal.scopes,
|
||||
)
|
||||
|
||||
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_manifest_registers_automation_resolution(self) -> None:
|
||||
self.assertIn(
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -17,7 +17,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from "@govoplan/core-webui";
|
||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||
import SystemUsersPanel from "./SystemUsersPanel";
|
||||
@@ -25,7 +26,14 @@ import ExternalFunctionRoleMappingsPanel from "./ExternalFunctionRoleMappingsPan
|
||||
import ApiKeysPanel from "./ApiKeysPanel";
|
||||
import FileConnectorsPanel from "./FileConnectorsPanel";
|
||||
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 OrderedAdminNavItem = { id: AdminSection; label: string; order: number };
|
||||
@@ -43,6 +51,7 @@ const handledAdminSectionIds = new Set<string>([
|
||||
"system-users",
|
||||
"system-file-connectors",
|
||||
"system-mail-servers",
|
||||
"system-credentials",
|
||||
"tenant-settings",
|
||||
"tenant-roles",
|
||||
"tenant-function-role-mappings",
|
||||
@@ -50,13 +59,38 @@ const handledAdminSectionIds = new Set<string>([
|
||||
"tenant-users",
|
||||
"tenant-file-connectors",
|
||||
"tenant-mail-servers",
|
||||
"tenant-credentials",
|
||||
"tenant-api-keys",
|
||||
"tenant-group-file-connectors",
|
||||
"tenant-group-mail-servers",
|
||||
"tenant-group-credentials",
|
||||
"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"
|
||||
};
|
||||
|
||||
export default function AdminPage({
|
||||
settings,
|
||||
auth,
|
||||
@@ -70,14 +104,23 @@ export default function AdminPage({
|
||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||
const effectiveView = useEffectiveView();
|
||||
const viewSurfaces = useViewSurfaces();
|
||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
||||
const contributedSections = useMemo(
|
||||
() =>
|
||||
adminSectionCapabilities
|
||||
.flatMap((capability) => capability.sections)
|
||||
.filter((section) =>
|
||||
isViewSurfaceVisible(
|
||||
effectiveView,
|
||||
section.surfaceId,
|
||||
viewSurfaces
|
||||
)
|
||||
)
|
||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||
[adminSectionCapabilities]
|
||||
[adminSectionCapabilities, effectiveView, viewSurfaces]
|
||||
);
|
||||
const contributionById = useMemo(() => {
|
||||
const mapped = new Map<string, AdminSectionContribution>();
|
||||
@@ -95,6 +138,9 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "system:settings:read")) {
|
||||
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 (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");
|
||||
@@ -113,8 +159,21 @@ export default function AdminPage({
|
||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
||||
}
|
||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||
return sections;
|
||||
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
|
||||
if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
|
||||
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 requestedSection = searchParams.get("section") as AdminSection | null;
|
||||
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
||||
@@ -137,11 +196,13 @@ export default function AdminPage({
|
||||
|
||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad">
|
||||
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
|
||||
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
|
||||
</Card>
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,6 +231,7 @@ export default function AdminPage({
|
||||
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-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, "SYSTEM", handledAdminSectionIds)
|
||||
])
|
||||
@@ -185,7 +247,8 @@ export default function AdminPage({
|
||||
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-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),
|
||||
...contributedNavItems(contributedSections, available, "TENANT", handledAdminSectionIds)
|
||||
])
|
||||
@@ -197,6 +260,7 @@ export default function AdminPage({
|
||||
sortNavItems([
|
||||
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-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
|
||||
...contributedNavItems(contributedSections, available, "GROUP", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
@@ -207,6 +271,7 @@ export default function AdminPage({
|
||||
sortNavItems([
|
||||
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-credentials", "i18n:govoplan-core.credentials.dd097a22", 30),
|
||||
...contributedNavItems(contributedSections, available, "USER", handledAdminSectionIds)
|
||||
])
|
||||
)
|
||||
@@ -224,6 +289,13 @@ export default function AdminPage({
|
||||
{!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")} />
|
||||
)}
|
||||
{!contributedSection && active === "system-credentials" && (
|
||||
<CredentialEnvelopesPanel
|
||||
settings={settings}
|
||||
scopeType="system"
|
||||
canWrite={hasAnyScope(auth, ["system:settings:write", "access:system_credential:write"])}
|
||||
/>
|
||||
)}
|
||||
{!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} />
|
||||
)}
|
||||
@@ -245,8 +317,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-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-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-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-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} />}
|
||||
|
||||
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}.`;
|
||||
}
|
||||
@@ -10,6 +10,23 @@ const translations = {
|
||||
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) {
|
||||
if (!onAuthChange) {
|
||||
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",
|
||||
version: "1.0.0",
|
||||
translations,
|
||||
viewSurfaces: accessAdminSurfaces,
|
||||
navItems: [
|
||||
{ to: "/admin", label: "i18n:govoplan-access.admin.4e7afebc", iconName: "admin", anyOf: adminReadScopes, order: 900 }],
|
||||
|
||||
|
||||
Reference in New Issue
Block a user