Compare commits
9 Commits
f1d64d247e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a758c8f2da | |||
| 6295cfa840 | |||
| b6c2c89adf | |||
| 6b0dd8beab | |||
| 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
|
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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
|||||||
)
|
)
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
collect_direct_user_roles,
|
collect_direct_user_roles,
|
||||||
collect_system_roles,
|
|
||||||
collect_user_groups,
|
collect_user_groups,
|
||||||
collect_user_scopes,
|
collect_user_scopes,
|
||||||
)
|
)
|
||||||
@@ -48,7 +47,11 @@ from govoplan_access.backend.db.models import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||||
from govoplan_core.core.organizations import OrganizationDirectory
|
from govoplan_core.core.organizations import OrganizationDirectory
|
||||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
from govoplan_access.backend.permissions.catalog import (
|
||||||
|
effective_permission_count,
|
||||||
|
expand_scopes,
|
||||||
|
scopes_grant,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||||
@@ -358,44 +361,249 @@ def _user_item(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
def _system_membership_rows(
|
||||||
memberships = (
|
session: Session,
|
||||||
|
account_ids: list[str],
|
||||||
|
) -> list[tuple[User, Tenant]]:
|
||||||
|
return (
|
||||||
session.query(User, Tenant)
|
session.query(User, Tenant)
|
||||||
.join(Tenant, Tenant.id == User.tenant_id)
|
.join(Tenant, Tenant.id == User.tenant_id)
|
||||||
.filter(User.account_id == account.id)
|
.filter(User.account_id.in_(account_ids))
|
||||||
.order_by(Tenant.name.asc())
|
.order_by(User.account_id.asc(), Tenant.name.asc(), User.id.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
owner_ids_by_tenant = {
|
|
||||||
tenant.id: tenant_owner_user_ids(session, tenant.id)
|
|
||||||
for _, tenant in memberships
|
def _memberships_by_account(
|
||||||
|
membership_rows: list[tuple[User, Tenant]],
|
||||||
|
) -> dict[str, list[tuple[User, Tenant]]]:
|
||||||
|
memberships_by_account: dict[str, list[tuple[User, Tenant]]] = defaultdict(list)
|
||||||
|
for user, tenant in membership_rows:
|
||||||
|
memberships_by_account[user.account_id].append((user, tenant))
|
||||||
|
return memberships_by_account
|
||||||
|
|
||||||
|
|
||||||
|
def _system_direct_roles_by_user(
|
||||||
|
session: Session,
|
||||||
|
user_ids: list[str],
|
||||||
|
) -> dict[str, list[Role]]:
|
||||||
|
roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||||
|
if not user_ids:
|
||||||
|
return roles_by_user
|
||||||
|
rows = (
|
||||||
|
session.query(UserRoleAssignment.user_id, Role)
|
||||||
|
.join(Role, Role.id == UserRoleAssignment.role_id)
|
||||||
|
.filter(UserRoleAssignment.user_id.in_(user_ids))
|
||||||
|
.order_by(UserRoleAssignment.user_id.asc(), Role.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user_id, role in rows:
|
||||||
|
roles_by_user[user_id].append(role)
|
||||||
|
return roles_by_user
|
||||||
|
|
||||||
|
|
||||||
|
def _system_groups_by_user(
|
||||||
|
session: Session,
|
||||||
|
user_ids: list[str],
|
||||||
|
) -> dict[str, list[Group]]:
|
||||||
|
groups_by_user: dict[str, list[Group]] = defaultdict(list)
|
||||||
|
if not user_ids:
|
||||||
|
return groups_by_user
|
||||||
|
rows = (
|
||||||
|
session.query(UserGroupMembership.user_id, Group)
|
||||||
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||||
|
.filter(
|
||||||
|
UserGroupMembership.user_id.in_(user_ids),
|
||||||
|
Group.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(UserGroupMembership.user_id.asc(), Group.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user_id, group in rows:
|
||||||
|
groups_by_user[user_id].append(group)
|
||||||
|
return groups_by_user
|
||||||
|
|
||||||
|
|
||||||
|
def _system_group_roles_by_user(
|
||||||
|
session: Session,
|
||||||
|
user_ids: list[str],
|
||||||
|
) -> dict[str, list[Role]]:
|
||||||
|
group_roles_by_user: dict[str, list[Role]] = defaultdict(list)
|
||||||
|
if not user_ids:
|
||||||
|
return group_roles_by_user
|
||||||
|
rows = (
|
||||||
|
session.query(UserGroupMembership.user_id, Role)
|
||||||
|
.join(
|
||||||
|
GroupRoleAssignment,
|
||||||
|
GroupRoleAssignment.group_id == UserGroupMembership.group_id,
|
||||||
|
)
|
||||||
|
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||||
|
.join(Group, Group.id == UserGroupMembership.group_id)
|
||||||
|
.filter(
|
||||||
|
UserGroupMembership.user_id.in_(user_ids),
|
||||||
|
Group.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for user_id, role in rows:
|
||||||
|
group_roles_by_user[user_id].append(role)
|
||||||
|
return group_roles_by_user
|
||||||
|
|
||||||
|
|
||||||
|
def _system_owner_ids_by_tenant(
|
||||||
|
membership_rows: list[tuple[User, Tenant]],
|
||||||
|
*,
|
||||||
|
accounts_by_id: dict[str, Account],
|
||||||
|
direct_roles_by_user: dict[str, list[Role]],
|
||||||
|
group_roles_by_user: dict[str, list[Role]],
|
||||||
|
) -> dict[str, set[str]]:
|
||||||
|
owner_ids_by_tenant: dict[str, set[str]] = defaultdict(set)
|
||||||
|
for user, tenant in membership_rows:
|
||||||
|
account = accounts_by_id[user.account_id]
|
||||||
|
if not user.is_active or not account.is_active:
|
||||||
|
continue
|
||||||
|
effective_permissions = [
|
||||||
|
permission
|
||||||
|
for role in direct_roles_by_user[user.id] + group_roles_by_user[user.id]
|
||||||
|
for permission in (role.permissions or [])
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
scopes_grant(effective_permissions, "admin:roles:write")
|
||||||
|
and scopes_grant(effective_permissions, "campaign:send")
|
||||||
|
):
|
||||||
|
owner_ids_by_tenant[tenant.id].add(user.id)
|
||||||
|
return owner_ids_by_tenant
|
||||||
|
|
||||||
|
|
||||||
|
def _system_roles_for_accounts(
|
||||||
|
session: Session,
|
||||||
|
account_ids: list[str],
|
||||||
|
) -> tuple[dict[str, list[Role]], dict[str, int]]:
|
||||||
|
system_roles_by_account: dict[str, list[Role]] = defaultdict(list)
|
||||||
|
system_role_ids: set[str] = set()
|
||||||
|
rows = (
|
||||||
|
session.query(SystemRoleAssignment.account_id, Role)
|
||||||
|
.join(Role, Role.id == SystemRoleAssignment.role_id)
|
||||||
|
.filter(
|
||||||
|
SystemRoleAssignment.account_id.in_(account_ids),
|
||||||
|
Role.tenant_id.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(SystemRoleAssignment.account_id.asc(), Role.name.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for account_id, role in rows:
|
||||||
|
system_roles_by_account[account_id].append(role)
|
||||||
|
system_role_ids.add(role.id)
|
||||||
|
return (
|
||||||
|
system_roles_by_account,
|
||||||
|
_system_role_assignment_counts(session, sorted(system_role_ids)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _system_membership_item(
|
||||||
|
user: User,
|
||||||
|
tenant: Tenant,
|
||||||
|
*,
|
||||||
|
roles_by_user: dict[str, list[Role]],
|
||||||
|
groups_by_user: dict[str, list[Group]],
|
||||||
|
owner_ids_by_tenant: dict[str, set[str]],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
tenant_owner_ids = owner_ids_by_tenant[tenant.id]
|
||||||
|
return {
|
||||||
|
"tenant_id": tenant.id,
|
||||||
|
"tenant_name": tenant.name,
|
||||||
|
"user_id": user.id,
|
||||||
|
"is_active": user.is_active and tenant.is_active,
|
||||||
|
"role_ids": [role.id for role in roles_by_user[user.id]],
|
||||||
|
"group_ids": [group.id for group in groups_by_user[user.id]],
|
||||||
|
"is_owner": user.id in tenant_owner_ids,
|
||||||
|
"is_last_active_owner": (
|
||||||
|
user.id in tenant_owner_ids and len(tenant_owner_ids) == 1
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _system_account_response_item(
|
||||||
|
session: Session,
|
||||||
|
account: Account,
|
||||||
|
*,
|
||||||
|
memberships: list[tuple[User, Tenant]],
|
||||||
|
roles_by_user: dict[str, list[Role]],
|
||||||
|
groups_by_user: dict[str, list[Group]],
|
||||||
|
owner_ids_by_tenant: dict[str, set[str]],
|
||||||
|
system_roles: list[Role],
|
||||||
|
system_role_counts: dict[str, int],
|
||||||
|
) -> SystemAccountItem:
|
||||||
return SystemAccountItem(
|
return SystemAccountItem(
|
||||||
account_id=account.id,
|
account_id=account.id,
|
||||||
email=account.email,
|
email=account.email,
|
||||||
display_name=account.display_name,
|
display_name=account.display_name,
|
||||||
is_active=account.is_active,
|
is_active=account.is_active,
|
||||||
memberships=[
|
memberships=[
|
||||||
{
|
_system_membership_item(
|
||||||
"tenant_id": tenant.id,
|
user,
|
||||||
"tenant_name": tenant.name,
|
tenant,
|
||||||
"user_id": user.id,
|
roles_by_user=roles_by_user,
|
||||||
"is_active": user.is_active and tenant.is_active,
|
groups_by_user=groups_by_user,
|
||||||
"role_ids": [role.id for role in collect_direct_user_roles(session, user)],
|
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||||
"group_ids": [group.id for group in collect_user_groups(session, user)],
|
)
|
||||||
"is_owner": user.id in owner_ids_by_tenant[tenant.id],
|
|
||||||
"is_last_active_owner": (
|
|
||||||
user.id in owner_ids_by_tenant[tenant.id]
|
|
||||||
and len(owner_ids_by_tenant[tenant.id]) == 1
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for user, tenant in memberships
|
for user, tenant in memberships
|
||||||
],
|
],
|
||||||
roles=[_role_summary(session, role) for role in collect_system_roles(session, account)],
|
roles=[
|
||||||
|
_role_summary(
|
||||||
|
session,
|
||||||
|
role,
|
||||||
|
system_role_assignment_counts=system_role_counts,
|
||||||
|
)
|
||||||
|
for role in system_roles
|
||||||
|
],
|
||||||
last_login_at=account.last_login_at,
|
last_login_at=account.last_login_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _system_account_items(
|
||||||
|
session: Session,
|
||||||
|
accounts: list[Account],
|
||||||
|
) -> list[SystemAccountItem]:
|
||||||
|
if not accounts:
|
||||||
|
return []
|
||||||
|
account_ids = [account.id for account in accounts]
|
||||||
|
accounts_by_id = {account.id: account for account in accounts}
|
||||||
|
membership_rows = _system_membership_rows(session, account_ids)
|
||||||
|
memberships_by_account = _memberships_by_account(membership_rows)
|
||||||
|
user_ids = [user.id for user, _tenant in membership_rows]
|
||||||
|
roles_by_user = _system_direct_roles_by_user(session, user_ids)
|
||||||
|
groups_by_user = _system_groups_by_user(session, user_ids)
|
||||||
|
group_roles_by_user = _system_group_roles_by_user(session, user_ids)
|
||||||
|
owner_ids_by_tenant = _system_owner_ids_by_tenant(
|
||||||
|
membership_rows,
|
||||||
|
accounts_by_id=accounts_by_id,
|
||||||
|
direct_roles_by_user=roles_by_user,
|
||||||
|
group_roles_by_user=group_roles_by_user,
|
||||||
|
)
|
||||||
|
system_roles_by_account, system_role_counts = _system_roles_for_accounts(
|
||||||
|
session,
|
||||||
|
account_ids,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
_system_account_response_item(
|
||||||
|
session,
|
||||||
|
account,
|
||||||
|
memberships=memberships_by_account[account.id],
|
||||||
|
roles_by_user=roles_by_user,
|
||||||
|
groups_by_user=groups_by_user,
|
||||||
|
owner_ids_by_tenant=owner_ids_by_tenant,
|
||||||
|
system_roles=system_roles_by_account[account.id],
|
||||||
|
system_role_counts=system_role_counts,
|
||||||
|
)
|
||||||
|
for account in accounts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _system_account_item(session: Session, account: Account) -> SystemAccountItem:
|
||||||
|
return _system_account_items(session, [account])[0]
|
||||||
|
|
||||||
|
|
||||||
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
def _api_key_item(session: Session, item: ApiKey, *, accounts_by_user_id: dict[str, Account] | None = None) -> ApiKeyAdminItem:
|
||||||
if accounts_by_user_id is not None:
|
if accounts_by_user_id is not None:
|
||||||
account = accounts_by_user_id.get(item.user_id)
|
account = accounts_by_user_id.get(item.user_id)
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ class OrganizationUnitItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class OrganizationUnitListResponse(BaseModel):
|
class OrganizationUnitListResponse(PagedListResponse):
|
||||||
organization_units: list[OrganizationUnitItem]
|
organization_units: list[OrganizationUnitItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ class FunctionAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionListResponse(BaseModel):
|
class FunctionListResponse(PagedListResponse):
|
||||||
functions: list[FunctionAdminItem]
|
functions: list[FunctionAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
class ExternalFunctionRoleMappingListResponse(PagedListResponse):
|
||||||
mappings: list[ExternalFunctionRoleMappingItem]
|
mappings: list[ExternalFunctionRoleMappingItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ class FunctionAssignmentAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionAssignmentListResponse(BaseModel):
|
class FunctionAssignmentListResponse(PagedListResponse):
|
||||||
assignments: list[FunctionAssignmentAdminItem]
|
assignments: list[FunctionAssignmentAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ class FunctionDelegationAdminItem(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class FunctionDelegationListResponse(BaseModel):
|
class FunctionDelegationListResponse(PagedListResponse):
|
||||||
delegations: list[FunctionDelegationAdminItem]
|
delegations: list[FunctionDelegationAdminItem]
|
||||||
|
|
||||||
|
|
||||||
@@ -876,6 +876,55 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
|
|||||||
secret: str
|
secret: str
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
tenant_id: str
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
scope_ceiling: list[str] = Field(default_factory=list)
|
||||||
|
is_active: bool
|
||||||
|
revision: int
|
||||||
|
created_by_account_id: str | None = None
|
||||||
|
updated_by_account_id: str | None = None
|
||||||
|
retired_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountListResponse(BaseModel):
|
||||||
|
items: list[ServiceAccountItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
scope_ceiling: list[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
max_length=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountUpdateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
scope_ceiling: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=200,
|
||||||
|
)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceAccountRetireRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
class AuditAdminItem(BaseModel):
|
class AuditAdminItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
scope: Literal["tenant", "system"] = "tenant"
|
scope: Literal["tenant", "system"] = "tenant"
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ from govoplan_access.backend.api.v1.admin_common import (
|
|||||||
_set_system_memberships,
|
_set_system_memberships,
|
||||||
_system_role_assignment_counts,
|
_system_role_assignment_counts,
|
||||||
_system_account_item,
|
_system_account_item,
|
||||||
|
_system_account_items,
|
||||||
_tenant_role_assignment_counts,
|
_tenant_role_assignment_counts,
|
||||||
_user_item,
|
_user_item,
|
||||||
)
|
)
|
||||||
@@ -176,6 +177,7 @@ from govoplan_core.core.change_sequence import (
|
|||||||
sequence_entries_since,
|
sequence_entries_since,
|
||||||
sequence_watermark_is_expired,
|
sequence_watermark_is_expired,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
Account,
|
Account,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
@@ -357,6 +359,16 @@ def _record_access_change(
|
|||||||
actor_id=principal.user.id,
|
actor_id=principal.user.id,
|
||||||
payload=payload or {},
|
payload=payload or {},
|
||||||
)
|
)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module=ACCESS_MODULE_ID,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=principal.user.id,
|
||||||
|
reason=operation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
|
def _require_any_permission(principal: ApiPrincipal, *scopes: str) -> None:
|
||||||
@@ -484,14 +496,42 @@ def _validate_parent_organization_unit(
|
|||||||
raise AdminValidationError("Parent organization unit does not belong to the tenant.")
|
raise AdminValidationError("Parent organization unit does not belong to the tenant.")
|
||||||
|
|
||||||
|
|
||||||
def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
def _function_role_ids_by_function_id(
|
||||||
role_ids = [
|
session: Session,
|
||||||
row[0]
|
function_ids: Iterable[str],
|
||||||
for row in session.query(FunctionRoleAssignment.role_id)
|
) -> dict[str, list[str]]:
|
||||||
.filter(FunctionRoleAssignment.function_id == item.id)
|
requested = tuple(dict.fromkeys(function_ids))
|
||||||
.order_by(FunctionRoleAssignment.created_at.asc())
|
result: dict[str, list[str]] = {function_id: [] for function_id in requested}
|
||||||
|
if not requested:
|
||||||
|
return result
|
||||||
|
rows = (
|
||||||
|
session.query(
|
||||||
|
FunctionRoleAssignment.function_id,
|
||||||
|
FunctionRoleAssignment.role_id,
|
||||||
|
)
|
||||||
|
.filter(FunctionRoleAssignment.function_id.in_(requested))
|
||||||
|
.order_by(
|
||||||
|
FunctionRoleAssignment.function_id.asc(),
|
||||||
|
FunctionRoleAssignment.created_at.asc(),
|
||||||
|
)
|
||||||
.all()
|
.all()
|
||||||
]
|
)
|
||||||
|
for function_id, role_id in rows:
|
||||||
|
result.setdefault(function_id, []).append(role_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _function_item(
|
||||||
|
session: Session,
|
||||||
|
item: Function,
|
||||||
|
*,
|
||||||
|
role_ids_by_function_id: dict[str, list[str]] | None = None,
|
||||||
|
) -> FunctionAdminItem:
|
||||||
|
if role_ids_by_function_id is None:
|
||||||
|
role_ids_by_function_id = _function_role_ids_by_function_id(
|
||||||
|
session,
|
||||||
|
(item.id,),
|
||||||
|
)
|
||||||
return FunctionAdminItem(
|
return FunctionAdminItem(
|
||||||
id=item.id,
|
id=item.id,
|
||||||
tenant_id=item.tenant_id,
|
tenant_id=item.tenant_id,
|
||||||
@@ -499,7 +539,7 @@ def _function_item(session: Session, item: Function) -> FunctionAdminItem:
|
|||||||
slug=item.slug,
|
slug=item.slug,
|
||||||
name=item.name,
|
name=item.name,
|
||||||
description=item.description,
|
description=item.description,
|
||||||
role_ids=role_ids,
|
role_ids=role_ids_by_function_id.get(item.id, []),
|
||||||
delegable=item.delegable,
|
delegable=item.delegable,
|
||||||
act_in_place_allowed=item.act_in_place_allowed,
|
act_in_place_allowed=item.act_in_place_allowed,
|
||||||
is_active=item.is_active,
|
is_active=item.is_active,
|
||||||
@@ -1266,17 +1306,26 @@ def deactivate_identity(
|
|||||||
@router.get("/organization-units", response_model=OrganizationUnitListResponse)
|
@router.get("/organization-units", response_model=OrganizationUnitListResponse)
|
||||||
def list_organization_units(
|
def list_organization_units(
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
items = (
|
query = (
|
||||||
session.query(OrganizationUnit)
|
session.query(OrganizationUnit)
|
||||||
.filter(OrganizationUnit.tenant_id == tenant.id)
|
.filter(OrganizationUnit.tenant_id == tenant.id)
|
||||||
.order_by(OrganizationUnit.name.asc())
|
.order_by(OrganizationUnit.name.asc())
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
return OrganizationUnitListResponse(organization_units=[_organization_unit_item(item) for item in items])
|
items, pagination = _page_query(
|
||||||
|
query,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return OrganizationUnitListResponse(
|
||||||
|
organization_units=[_organization_unit_item(item) for item in items],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/organization-units", response_model=OrganizationUnitItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -1394,6 +1443,8 @@ def deactivate_organization_unit(
|
|||||||
def list_functions(
|
def list_functions(
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
organization_unit_id: str | None = None,
|
organization_unit_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
@@ -1401,8 +1452,26 @@ def list_functions(
|
|||||||
query = session.query(Function).filter(Function.tenant_id == tenant.id)
|
query = session.query(Function).filter(Function.tenant_id == tenant.id)
|
||||||
if organization_unit_id:
|
if organization_unit_id:
|
||||||
query = query.filter(Function.organization_unit_id == organization_unit_id)
|
query = query.filter(Function.organization_unit_id == organization_unit_id)
|
||||||
functions = query.order_by(Function.name.asc()).all()
|
functions, pagination = _page_query(
|
||||||
return FunctionListResponse(functions=[_function_item(session, item) for item in functions])
|
query.order_by(Function.name.asc()),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
role_ids_by_function_id = _function_role_ids_by_function_id(
|
||||||
|
session,
|
||||||
|
(item.id for item in functions),
|
||||||
|
)
|
||||||
|
return FunctionListResponse(
|
||||||
|
functions=[
|
||||||
|
_function_item(
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
role_ids_by_function_id=role_ids_by_function_id,
|
||||||
|
)
|
||||||
|
for item in functions
|
||||||
|
],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/functions", response_model=FunctionAdminItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -1535,6 +1604,8 @@ def list_external_function_role_mappings(
|
|||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
source_module: str | None = None,
|
source_module: str | None = None,
|
||||||
function_id: str | None = None,
|
function_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
@@ -1544,23 +1615,57 @@ def list_external_function_role_mappings(
|
|||||||
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
|
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
|
||||||
if function_id:
|
if function_id:
|
||||||
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
|
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
|
||||||
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all()
|
items, pagination = _page_query(
|
||||||
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items])
|
query.order_by(
|
||||||
|
ExternalFunctionRoleAssignment.source_module.asc(),
|
||||||
|
ExternalFunctionRoleAssignment.function_id.asc(),
|
||||||
|
),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return ExternalFunctionRoleMappingListResponse(
|
||||||
|
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse:
|
def _full_external_function_role_mappings_delta_response(
|
||||||
items = (
|
session: Session,
|
||||||
|
tenant: Tenant,
|
||||||
|
*,
|
||||||
|
cursor: tuple[int, int] | None = None,
|
||||||
|
limit: int = 500,
|
||||||
|
) -> ExternalFunctionRoleMappingListDeltaResponse:
|
||||||
|
snapshot_sequence = (
|
||||||
|
cursor[1]
|
||||||
|
if cursor is not None
|
||||||
|
else max_sequence_id(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
module_id=ACCESS_MODULE_ID,
|
||||||
|
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
page = cursor[0] if cursor is not None else 1
|
||||||
|
query = (
|
||||||
session.query(ExternalFunctionRoleAssignment)
|
session.query(ExternalFunctionRoleAssignment)
|
||||||
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
||||||
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
|
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
|
||||||
.all()
|
)
|
||||||
|
items, pagination, watermark, has_more = _full_delta_page(
|
||||||
|
query,
|
||||||
|
page=page,
|
||||||
|
page_size=limit,
|
||||||
|
scope="external-function-role-mappings",
|
||||||
|
snapshot_sequence=snapshot_sequence,
|
||||||
)
|
)
|
||||||
return ExternalFunctionRoleMappingListDeltaResponse(
|
return ExternalFunctionRoleMappingListDeltaResponse(
|
||||||
mappings=[_external_function_role_mapping_item(item) for item in items],
|
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||||
deleted=[],
|
deleted=[],
|
||||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)),
|
watermark=watermark,
|
||||||
has_more=False,
|
has_more=has_more,
|
||||||
full=True,
|
full=True,
|
||||||
|
**pagination,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1620,8 +1725,17 @@ def list_external_function_role_mappings_delta(
|
|||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
if since is None:
|
full_cursor = _decode_full_delta_cursor(
|
||||||
return _full_external_function_role_mappings_delta_response(session, tenant)
|
since,
|
||||||
|
scope="external-function-role-mappings",
|
||||||
|
)
|
||||||
|
if since is None or full_cursor is not None:
|
||||||
|
return _full_external_function_role_mappings_delta_response(
|
||||||
|
session,
|
||||||
|
tenant,
|
||||||
|
cursor=full_cursor,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
|
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@@ -1735,6 +1849,8 @@ def list_function_assignments(
|
|||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
function_id: str | None = None,
|
function_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:assign")),
|
||||||
):
|
):
|
||||||
@@ -1744,8 +1860,15 @@ def list_function_assignments(
|
|||||||
query = query.filter(FunctionAssignment.account_id == account_id)
|
query = query.filter(FunctionAssignment.account_id == account_id)
|
||||||
if function_id:
|
if function_id:
|
||||||
query = query.filter(FunctionAssignment.function_id == function_id)
|
query = query.filter(FunctionAssignment.function_id == function_id)
|
||||||
assignments = query.order_by(FunctionAssignment.created_at.desc()).all()
|
assignments, pagination = _page_query(
|
||||||
return FunctionAssignmentListResponse(assignments=[_function_assignment_item(item) for item in assignments])
|
query.order_by(FunctionAssignment.created_at.desc()),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return FunctionAssignmentListResponse(
|
||||||
|
assignments=[_function_assignment_item(item) for item in assignments],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/function-assignments", response_model=FunctionAssignmentAdminItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -1888,6 +2011,8 @@ def deactivate_function_assignment(
|
|||||||
def list_function_delegations(
|
def list_function_delegations(
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
account_id: str | None = None,
|
account_id: str | None = None,
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=500, ge=1, le=1000),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:function:delegate")),
|
||||||
):
|
):
|
||||||
@@ -1895,8 +2020,15 @@ def list_function_delegations(
|
|||||||
query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id)
|
query = session.query(FunctionDelegation).filter(FunctionDelegation.tenant_id == tenant.id)
|
||||||
if account_id:
|
if account_id:
|
||||||
query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id))
|
query = query.filter((FunctionDelegation.delegator_account_id == account_id) | (FunctionDelegation.delegate_account_id == account_id))
|
||||||
delegations = query.order_by(FunctionDelegation.created_at.desc()).all()
|
delegations, pagination = _page_query(
|
||||||
return FunctionDelegationListResponse(delegations=[_function_delegation_item(item) for item in delegations])
|
query.order_by(FunctionDelegation.created_at.desc()),
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return FunctionDelegationListResponse(
|
||||||
|
delegations=[_function_delegation_item(item) for item in delegations],
|
||||||
|
**pagination,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED)
|
@router.post("/function-delegations", response_model=FunctionDelegationAdminItem, status_code=status.HTTP_201_CREATED)
|
||||||
@@ -2981,6 +3113,25 @@ def _system_role_summaries_for_response(session: Session, roles: list[Role]) ->
|
|||||||
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
|
return [_role_summary(session, role, system_role_assignment_counts=system_role_counts) for role in roles]
|
||||||
|
|
||||||
|
|
||||||
|
def _system_roles_for_account_catalog(session: Session) -> list[Role]:
|
||||||
|
roles = (
|
||||||
|
session.query(Role)
|
||||||
|
.filter(Role.tenant_id.is_(None))
|
||||||
|
.order_by(Role.name.asc(), Role.id.asc())
|
||||||
|
.limit(1001)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if len(roles) > 1000:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||||
|
detail=(
|
||||||
|
"The system role catalog exceeds 1000 entries. "
|
||||||
|
"Use the paginated system roles endpoint."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
|
def _full_system_roles_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> RoleListDeltaResponse:
|
||||||
ensure_default_roles(session, None)
|
ensure_default_roles(session, None)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -3178,13 +3329,13 @@ _SYSTEM_ACCOUNTS_DELTA_COLLECTIONS = (ACCESS_SYSTEM_ACCOUNTS_COLLECTION, ACCESS_
|
|||||||
def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
|
def _full_system_accounts_delta_response(session: Session, *, cursor: tuple[int, int] | None = None, limit: int = 500) -> SystemAccountListDeltaResponse:
|
||||||
ensure_default_roles(session, None)
|
ensure_default_roles(session, None)
|
||||||
session.commit()
|
session.commit()
|
||||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
system_roles = _system_roles_for_account_catalog(session)
|
||||||
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS)
|
snapshot_sequence = cursor[1] if cursor is not None else max_sequence_id(session, tenant_id=None, module_id=ACCESS_MODULE_ID, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS)
|
||||||
page = cursor[0] if cursor is not None else 1
|
page = cursor[0] if cursor is not None else 1
|
||||||
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc())
|
query = session.query(Account).order_by(Account.email.asc(), Account.id.asc())
|
||||||
accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
|
accounts, pagination, watermark, has_more = _full_delta_page(query, page=page, page_size=limit, scope="system-accounts", snapshot_sequence=snapshot_sequence)
|
||||||
return SystemAccountListDeltaResponse(
|
return SystemAccountListDeltaResponse(
|
||||||
accounts=[_system_account_item(session, account) for account in accounts],
|
accounts=_system_account_items(session, accounts),
|
||||||
roles=_system_role_summaries_for_response(session, system_roles),
|
roles=_system_role_summaries_for_response(session, system_roles),
|
||||||
deleted=[],
|
deleted=[],
|
||||||
watermark=watermark,
|
watermark=watermark,
|
||||||
@@ -3223,7 +3374,7 @@ def _system_accounts_delta_response(session: Session, *, since: str, limit: int)
|
|||||||
)
|
)
|
||||||
]
|
]
|
||||||
return SystemAccountListDeltaResponse(
|
return SystemAccountListDeltaResponse(
|
||||||
accounts=[_system_account_item(session, account) for account in visible_accounts.values()],
|
accounts=_system_account_items(session, list(visible_accounts.values())),
|
||||||
roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
|
roles=_system_role_summaries_for_response(session, list(visible_roles.values())),
|
||||||
deleted=deleted,
|
deleted=deleted,
|
||||||
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more),
|
watermark=_access_delta_response_watermark(session, tenant_id=None, collections=_SYSTEM_ACCOUNTS_DELTA_COLLECTIONS, entries=entries, has_more=has_more),
|
||||||
@@ -3255,11 +3406,11 @@ def list_system_accounts(
|
|||||||
):
|
):
|
||||||
ensure_default_roles(session, None)
|
ensure_default_roles(session, None)
|
||||||
session.commit()
|
session.commit()
|
||||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.name.asc()).all()
|
system_roles = _system_roles_for_account_catalog(session)
|
||||||
query = session.query(Account).order_by(Account.email.asc())
|
query = session.query(Account).order_by(Account.email.asc())
|
||||||
accounts, pagination = _page_query(query, page=page, page_size=page_size)
|
accounts, pagination = _page_query(query, page=page, page_size=page_size)
|
||||||
return SystemAccountListResponse(
|
return SystemAccountListResponse(
|
||||||
accounts=[_system_account_item(session, account) for account in accounts],
|
accounts=_system_account_items(session, accounts),
|
||||||
roles=_system_role_summaries_for_response(session, system_roles),
|
roles=_system_role_summaries_for_response(session, system_roles),
|
||||||
**pagination,
|
**pagination,
|
||||||
)
|
)
|
||||||
|
|||||||
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"]
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Request, status
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -15,15 +17,32 @@ from govoplan_core.core.access import (
|
|||||||
PrincipalRef,
|
PrincipalRef,
|
||||||
PrincipalResolver,
|
PrincipalResolver,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.automation import (
|
||||||
|
AutomationPrincipalRequest,
|
||||||
|
AutomationPrincipalResolution,
|
||||||
|
)
|
||||||
from govoplan_core.core.identity import IdentityDirectory
|
from govoplan_core.core.identity import IdentityDirectory
|
||||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||||
from govoplan_core.core.organizations import OrganizationDirectory
|
from govoplan_core.core.organizations import OrganizationDirectory
|
||||||
|
from govoplan_core.core.principal_cache import (
|
||||||
|
auth_principal_revision,
|
||||||
|
)
|
||||||
from govoplan_core.core.modules import AccessDecision
|
from govoplan_core.core.modules import AccessDecision
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||||
from govoplan_core.db.session import get_database, get_session
|
from govoplan_core.db.session import get_database, get_session
|
||||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
|
from govoplan_access.backend.db.models import (
|
||||||
|
Account,
|
||||||
|
ApiKey,
|
||||||
|
AuthSession,
|
||||||
|
Role,
|
||||||
|
ServiceAccount,
|
||||||
|
Tenant,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
|
from govoplan_access.backend.auth.tokens import hash_secret
|
||||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||||
from govoplan_access.backend.security.sessions import (
|
from govoplan_access.backend.security.sessions import (
|
||||||
authenticate_session_token,
|
authenticate_session_token,
|
||||||
@@ -32,6 +51,7 @@ from govoplan_access.backend.security.sessions import (
|
|||||||
verify_auth_session_csrf,
|
verify_auth_session_csrf,
|
||||||
)
|
)
|
||||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||||
|
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||||
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
|
from govoplan_access.backend.permissions.catalog import intersect_api_key_scopes
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
|
||||||
@@ -191,6 +211,15 @@ def _resolve_legacy_principal_context(
|
|||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key or session token")
|
||||||
|
|
||||||
|
cached = _cached_principal_context(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
if source != "cookie":
|
if source != "cookie":
|
||||||
context = _resolve_api_key_principal_context(
|
context = _resolve_api_key_principal_context(
|
||||||
session,
|
session,
|
||||||
@@ -200,7 +229,14 @@ def _resolve_legacy_principal_context(
|
|||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
)
|
)
|
||||||
if context is not None:
|
if context is not None:
|
||||||
return context
|
return _cache_resolved_principal_context(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
context=context,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
|
||||||
context = _resolve_session_principal_context(
|
context = _resolve_session_principal_context(
|
||||||
request,
|
request,
|
||||||
@@ -212,11 +248,226 @@ def _resolve_legacy_principal_context(
|
|||||||
organization_directory=organization_directory,
|
organization_directory=organization_directory,
|
||||||
)
|
)
|
||||||
if context is not None:
|
if context is not None:
|
||||||
return context
|
return _cache_resolved_principal_context(
|
||||||
|
session,
|
||||||
|
token=token,
|
||||||
|
context=context,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_principal_context(
|
||||||
|
request: Request,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
source: str,
|
||||||
|
) -> ResolvedPrincipalContext | None:
|
||||||
|
if not settings.auth_principal_cache_enabled:
|
||||||
|
return None
|
||||||
|
token_digest = hash_secret(token)
|
||||||
|
entry = principal_summary_cache.get(
|
||||||
|
token_digest,
|
||||||
|
session_ttl_seconds=settings.auth_principal_cache_session_ttl_seconds,
|
||||||
|
api_key_ttl_seconds=settings.auth_principal_cache_api_key_ttl_seconds,
|
||||||
|
)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
before = auth_principal_revision(session, tenant_id=entry.principal.tenant_id)
|
||||||
|
if before != entry.revision:
|
||||||
|
principal_summary_cache.discard(token_digest)
|
||||||
|
return None
|
||||||
|
context = _rehydrate_cached_principal(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
token_digest=token_digest,
|
||||||
|
source=source,
|
||||||
|
principal=entry.principal,
|
||||||
|
)
|
||||||
|
after = auth_principal_revision(session, tenant_id=entry.principal.tenant_id)
|
||||||
|
if context is None or after != before:
|
||||||
|
principal_summary_cache.discard(token_digest)
|
||||||
|
return None
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
def _rehydrate_cached_principal(
|
||||||
|
request: Request,
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token_digest: str,
|
||||||
|
source: str,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
) -> ResolvedPrincipalContext | None:
|
||||||
|
account = session.get(Account, principal.account_id)
|
||||||
|
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||||
|
tenant = session.get(Tenant, principal.tenant_id) if principal.tenant_id else None
|
||||||
|
if (
|
||||||
|
not account
|
||||||
|
or not user
|
||||||
|
or not tenant
|
||||||
|
or not account.is_active
|
||||||
|
or not user.is_active
|
||||||
|
or not tenant.is_active
|
||||||
|
or user.account_id != account.id
|
||||||
|
or user.tenant_id != tenant.id
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if principal.auth_method == "api_key":
|
||||||
|
api_key = session.get(ApiKey, principal.api_key_id) if principal.api_key_id else None
|
||||||
|
if (
|
||||||
|
api_key is None
|
||||||
|
or api_key.key_hash != token_digest
|
||||||
|
or api_key.revoked_at is not None
|
||||||
|
or api_key.user_id != user.id
|
||||||
|
or api_key.tenant_id != tenant.id
|
||||||
|
or _is_expired(api_key.expires_at, allow_none=True)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
_touch_auth_activity(
|
||||||
|
session,
|
||||||
|
api_key,
|
||||||
|
field="last_used_at",
|
||||||
|
)
|
||||||
|
return ResolvedPrincipalContext(
|
||||||
|
principal=principal,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_session = session.get(AuthSession, principal.session_id) if principal.session_id else None
|
||||||
|
if (
|
||||||
|
auth_session is None
|
||||||
|
or auth_session.token_hash != token_digest
|
||||||
|
or auth_session.revoked_at is not None
|
||||||
|
or auth_session.user_id != user.id
|
||||||
|
or auth_session.account_id != account.id
|
||||||
|
or auth_session.tenant_id != tenant.id
|
||||||
|
or _is_expired(auth_session.expires_at)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
if source == "cookie":
|
||||||
|
_verify_session_csrf(request, auth_session)
|
||||||
|
_touch_auth_activity(
|
||||||
|
session,
|
||||||
|
auth_session,
|
||||||
|
field="last_seen_at",
|
||||||
|
)
|
||||||
|
return ResolvedPrincipalContext(
|
||||||
|
principal=principal,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant=tenant,
|
||||||
|
auth_session=auth_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_expired(value, *, allow_none: bool = False) -> bool:
|
||||||
|
expires_at = ensure_aware_utc(value)
|
||||||
|
return (not allow_none and expires_at is None) or (
|
||||||
|
expires_at is not None and expires_at < utc_now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _touch_auth_activity(
|
||||||
|
session: Session,
|
||||||
|
model: ApiKey | AuthSession,
|
||||||
|
*,
|
||||||
|
field: str,
|
||||||
|
) -> None:
|
||||||
|
now = utc_now()
|
||||||
|
previous = ensure_aware_utc(getattr(model, field))
|
||||||
|
interval = settings.auth_activity_touch_interval_seconds
|
||||||
|
if interval <= 0 or previous is None or now - previous >= timedelta(seconds=interval):
|
||||||
|
setattr(model, field, now)
|
||||||
|
session.add(model)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_resolved_principal_context(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
token: str,
|
||||||
|
context: ResolvedPrincipalContext,
|
||||||
|
idm_directory: IdmDirectory | None,
|
||||||
|
identity_directory: IdentityDirectory | None,
|
||||||
|
organization_directory: OrganizationDirectory | None,
|
||||||
|
) -> ResolvedPrincipalContext:
|
||||||
|
if not settings.auth_principal_cache_enabled:
|
||||||
|
return context
|
||||||
|
before = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||||
|
refreshed = _refresh_principal_context(
|
||||||
|
session,
|
||||||
|
context=context,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
after = auth_principal_revision(session, tenant_id=context.principal.tenant_id)
|
||||||
|
if before == after:
|
||||||
|
principal_summary_cache.put(
|
||||||
|
hash_secret(token),
|
||||||
|
principal=refreshed.principal,
|
||||||
|
revision=after,
|
||||||
|
max_entries=settings.auth_principal_cache_max_entries,
|
||||||
|
)
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_principal_context(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
context: ResolvedPrincipalContext,
|
||||||
|
idm_directory: IdmDirectory | None,
|
||||||
|
identity_directory: IdentityDirectory | None,
|
||||||
|
organization_directory: OrganizationDirectory | None,
|
||||||
|
) -> ResolvedPrincipalContext:
|
||||||
|
idm_assignments, idm_roles = _principal_idm_context(
|
||||||
|
session,
|
||||||
|
user=context.user,
|
||||||
|
account=context.account,
|
||||||
|
tenant_id=context.tenant.id,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
include_system = context.auth_session is not None
|
||||||
|
authorization_context = collect_user_authorization_context(
|
||||||
|
session,
|
||||||
|
context.user,
|
||||||
|
account=context.account,
|
||||||
|
include_system=include_system,
|
||||||
|
extra_roles=idm_roles,
|
||||||
|
)
|
||||||
|
scopes = authorization_context.scopes
|
||||||
|
auth_method = "session"
|
||||||
|
if context.api_key is not None:
|
||||||
|
scopes = intersect_api_key_scopes(scopes, context.api_key.scopes or [])
|
||||||
|
auth_method = "api_key"
|
||||||
|
principal = _build_principal_ref(
|
||||||
|
session,
|
||||||
|
account=context.account,
|
||||||
|
user=context.user,
|
||||||
|
tenant_id=context.tenant.id,
|
||||||
|
scopes=scopes,
|
||||||
|
auth_method=auth_method,
|
||||||
|
api_key=context.api_key,
|
||||||
|
auth_session=context.auth_session,
|
||||||
|
idm_assignments=idm_assignments,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
extra_roles=idm_roles,
|
||||||
|
authorization_context=authorization_context,
|
||||||
|
include_system_roles=include_system,
|
||||||
|
)
|
||||||
|
return replace(context, principal=principal)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_api_key_principal_ref(
|
def _resolve_api_key_principal_ref(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -245,11 +496,19 @@ def _resolve_api_key_principal_context(
|
|||||||
) -> ResolvedPrincipalContext | None:
|
) -> ResolvedPrincipalContext | None:
|
||||||
# API keys remain supported for CLI/automation. Their permissions are the
|
# API keys remain supported for CLI/automation. Their permissions are the
|
||||||
# intersection of the key grant and the owner's current tenant roles.
|
# intersection of the key grant and the owner's current tenant roles.
|
||||||
api_key = authenticate_api_key(session, token)
|
api_key = authenticate_api_key(
|
||||||
|
session,
|
||||||
|
token,
|
||||||
|
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
|
||||||
|
)
|
||||||
if api_key is None:
|
if api_key is None:
|
||||||
return None
|
return None
|
||||||
user = session.get(User, api_key.user_id)
|
activity_touch_pending = session.is_modified(
|
||||||
account = session.get(Account, user.account_id) if user else None
|
api_key,
|
||||||
|
include_collections=False,
|
||||||
|
)
|
||||||
|
user = api_key.user
|
||||||
|
account = user.account if user else None
|
||||||
tenant = session.get(Tenant, api_key.tenant_id)
|
tenant = session.get(Tenant, api_key.tenant_id)
|
||||||
if (
|
if (
|
||||||
not user or not account or not tenant
|
not user or not account or not tenant
|
||||||
@@ -273,7 +532,6 @@ def _resolve_api_key_principal_context(
|
|||||||
extra_roles=idm_roles,
|
extra_roles=idm_roles,
|
||||||
)
|
)
|
||||||
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
effective_scopes = intersect_api_key_scopes(authorization_context.scopes, api_key.scopes or [])
|
||||||
session.commit()
|
|
||||||
principal = _build_principal_ref(
|
principal = _build_principal_ref(
|
||||||
session,
|
session,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
@@ -287,6 +545,8 @@ def _resolve_api_key_principal_context(
|
|||||||
extra_roles=idm_roles,
|
extra_roles=idm_roles,
|
||||||
authorization_context=authorization_context,
|
authorization_context=authorization_context,
|
||||||
)
|
)
|
||||||
|
if activity_touch_pending:
|
||||||
|
session.commit()
|
||||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, api_key=api_key)
|
||||||
|
|
||||||
|
|
||||||
@@ -322,11 +582,19 @@ def _resolve_session_principal_context(
|
|||||||
identity_directory: IdentityDirectory | None,
|
identity_directory: IdentityDirectory | None,
|
||||||
organization_directory: OrganizationDirectory | None,
|
organization_directory: OrganizationDirectory | None,
|
||||||
) -> ResolvedPrincipalContext | None:
|
) -> ResolvedPrincipalContext | None:
|
||||||
auth_session = authenticate_session_token(session, token)
|
auth_session = authenticate_session_token(
|
||||||
|
session,
|
||||||
|
token,
|
||||||
|
touch_interval_seconds=settings.auth_activity_touch_interval_seconds,
|
||||||
|
)
|
||||||
if auth_session is None:
|
if auth_session is None:
|
||||||
return None
|
return None
|
||||||
user = session.get(User, auth_session.user_id)
|
activity_touch_pending = session.is_modified(
|
||||||
account = session.get(Account, auth_session.account_id)
|
auth_session,
|
||||||
|
include_collections=False,
|
||||||
|
)
|
||||||
|
user = auth_session.user
|
||||||
|
account = auth_session.account
|
||||||
tenant = session.get(Tenant, auth_session.tenant_id)
|
tenant = session.get(Tenant, auth_session.tenant_id)
|
||||||
if (
|
if (
|
||||||
not user or not account or not tenant
|
not user or not account or not tenant
|
||||||
@@ -353,7 +621,6 @@ def _resolve_session_principal_context(
|
|||||||
extra_roles=idm_roles,
|
extra_roles=idm_roles,
|
||||||
)
|
)
|
||||||
scopes = authorization_context.scopes
|
scopes = authorization_context.scopes
|
||||||
session.commit()
|
|
||||||
principal = _build_principal_ref(
|
principal = _build_principal_ref(
|
||||||
session,
|
session,
|
||||||
auth_session=auth_session,
|
auth_session=auth_session,
|
||||||
@@ -368,6 +635,8 @@ def _resolve_session_principal_context(
|
|||||||
authorization_context=authorization_context,
|
authorization_context=authorization_context,
|
||||||
include_system_roles=True,
|
include_system_roles=True,
|
||||||
)
|
)
|
||||||
|
if activity_touch_pending:
|
||||||
|
session.commit()
|
||||||
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
return ResolvedPrincipalContext(principal=principal, account=account, user=user, tenant=tenant, auth_session=auth_session)
|
||||||
|
|
||||||
|
|
||||||
@@ -558,6 +827,368 @@ class AccessApiPrincipalProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessAutomationPrincipalProvider:
|
||||||
|
"""Rebuild a trigger owner against current tenant authorization."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
idm_directory: IdmDirectory | None = None,
|
||||||
|
identity_directory: IdentityDirectory | None = None,
|
||||||
|
organization_directory: OrganizationDirectory | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._idm_directory = idm_directory
|
||||||
|
self._identity_directory = identity_directory
|
||||||
|
self._organization_directory = organization_directory
|
||||||
|
|
||||||
|
def resolve_automation_principal(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError(
|
||||||
|
"Access automation principal resolution requires a "
|
||||||
|
"SQLAlchemy session"
|
||||||
|
)
|
||||||
|
if request.subject_kind == "service_account":
|
||||||
|
return _resolve_service_account_automation(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return _resolve_delegated_user_automation(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
idm_directory=self._idm_directory,
|
||||||
|
identity_directory=self._identity_directory,
|
||||||
|
organization_directory=self._organization_directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_delegated_user_automation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
idm_directory: IdmDirectory | None,
|
||||||
|
identity_directory: IdentityDirectory | None,
|
||||||
|
organization_directory: OrganizationDirectory | None,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
account = session.get(Account, request.account_id)
|
||||||
|
user = session.get(User, request.membership_id)
|
||||||
|
tenant = session.get(Tenant, request.tenant_id)
|
||||||
|
if (
|
||||||
|
account is None
|
||||||
|
or user is None
|
||||||
|
or tenant is None
|
||||||
|
or not account.is_active
|
||||||
|
or not user.is_active
|
||||||
|
or not tenant.is_active
|
||||||
|
or user.account_id != account.id
|
||||||
|
or user.tenant_id != tenant.id
|
||||||
|
):
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="inactive_or_inconsistent",
|
||||||
|
reason=(
|
||||||
|
"The automation owner is inactive, missing, or no longer "
|
||||||
|
"belongs to the tenant."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
idm_assignments, idm_roles = _principal_idm_context(
|
||||||
|
session,
|
||||||
|
user=user,
|
||||||
|
account=account,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
authorization_context = collect_user_authorization_context(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
account=account,
|
||||||
|
include_system=False,
|
||||||
|
extra_roles=idm_roles,
|
||||||
|
)
|
||||||
|
granted_scopes, missing_scopes = _current_trigger_grants(
|
||||||
|
request.grant_scopes,
|
||||||
|
current_scopes=authorization_context.scopes,
|
||||||
|
)
|
||||||
|
if missing_scopes:
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="authorization_reduced",
|
||||||
|
reason=(
|
||||||
|
"The automation owner no longer has every scope granted "
|
||||||
|
"to this trigger."
|
||||||
|
),
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
missing_scopes=missing_scopes,
|
||||||
|
)
|
||||||
|
principal_ref = _build_principal_ref(
|
||||||
|
session,
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
scopes=list(granted_scopes),
|
||||||
|
auth_method="service_account",
|
||||||
|
idm_assignments=idm_assignments,
|
||||||
|
identity_directory=identity_directory,
|
||||||
|
extra_roles=idm_roles,
|
||||||
|
authorization_context=authorization_context,
|
||||||
|
include_system_roles=False,
|
||||||
|
)
|
||||||
|
principal_ref = replace(
|
||||||
|
principal_ref,
|
||||||
|
service_account_id=None,
|
||||||
|
acting_for_account_id=account.id,
|
||||||
|
)
|
||||||
|
return _automation_allowed(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
principal_ref=principal_ref,
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_service_account_automation(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
item = session.get(ServiceAccount, request.service_account_id)
|
||||||
|
tenant = session.get(Tenant, request.tenant_id)
|
||||||
|
account = (
|
||||||
|
session.get(Account, item.account_id)
|
||||||
|
if item is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
user = (
|
||||||
|
session.get(User, item.membership_id)
|
||||||
|
if item is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
item is None
|
||||||
|
or tenant is None
|
||||||
|
or account is None
|
||||||
|
or user is None
|
||||||
|
or item.tenant_id != request.tenant_id
|
||||||
|
or not item.is_active
|
||||||
|
or not tenant.is_active
|
||||||
|
or not account.is_active
|
||||||
|
or not user.is_active
|
||||||
|
or item.account_id != account.id
|
||||||
|
or item.membership_id != user.id
|
||||||
|
or user.account_id != account.id
|
||||||
|
or user.tenant_id != tenant.id
|
||||||
|
or account.auth_provider != "service_account"
|
||||||
|
or user.auth_provider != "service_account"
|
||||||
|
):
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="inactive_or_inconsistent",
|
||||||
|
reason=(
|
||||||
|
"The service account is inactive, missing, or no longer "
|
||||||
|
"belongs to the tenant."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
granted_scopes, missing_scopes = _current_trigger_grants(
|
||||||
|
request.grant_scopes,
|
||||||
|
current_scopes=item.scope_ceiling,
|
||||||
|
)
|
||||||
|
if missing_scopes:
|
||||||
|
return _automation_denied(
|
||||||
|
request,
|
||||||
|
status="authorization_reduced",
|
||||||
|
reason=(
|
||||||
|
"The service account no longer has every scope granted "
|
||||||
|
"to this trigger."
|
||||||
|
),
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
missing_scopes=missing_scopes,
|
||||||
|
)
|
||||||
|
principal_ref = PrincipalRef(
|
||||||
|
account_id=account.id,
|
||||||
|
membership_id=user.id,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
scopes=frozenset(granted_scopes),
|
||||||
|
auth_method="service_account",
|
||||||
|
service_account_id=item.id,
|
||||||
|
email=None,
|
||||||
|
display_name=item.name,
|
||||||
|
)
|
||||||
|
return _automation_allowed(
|
||||||
|
session,
|
||||||
|
request=request,
|
||||||
|
principal_ref=principal_ref,
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_trigger_grants(
|
||||||
|
trigger_scopes: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
current_scopes: list[str] | tuple[str, ...],
|
||||||
|
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||||
|
granted = tuple(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
required
|
||||||
|
for required in trigger_scopes
|
||||||
|
if scopes_grant_compatible(
|
||||||
|
current_scopes,
|
||||||
|
required,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
missing = tuple(
|
||||||
|
sorted(set(trigger_scopes) - set(granted))
|
||||||
|
)
|
||||||
|
return granted, missing
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_allowed(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
principal_ref: PrincipalRef,
|
||||||
|
granted_scopes: tuple[str, ...],
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
api_principal = _api_principal_from_ref(
|
||||||
|
session,
|
||||||
|
principal_ref,
|
||||||
|
permission_evaluator=LegacyPermissionEvaluator(),
|
||||||
|
)
|
||||||
|
provenance = _automation_provenance(
|
||||||
|
request,
|
||||||
|
status="current_authorization_resolved",
|
||||||
|
current_principal={
|
||||||
|
"kind": request.subject_kind,
|
||||||
|
"account_id": principal_ref.account_id,
|
||||||
|
"membership_id": principal_ref.membership_id,
|
||||||
|
"service_account_id": principal_ref.service_account_id,
|
||||||
|
"role_ids": sorted(principal_ref.role_ids),
|
||||||
|
"group_ids": sorted(principal_ref.group_ids),
|
||||||
|
"function_assignment_ids": sorted(
|
||||||
|
principal_ref.function_assignment_ids
|
||||||
|
),
|
||||||
|
"delegation_ids": sorted(
|
||||||
|
principal_ref.delegation_ids
|
||||||
|
),
|
||||||
|
"granted_scopes": list(granted_scopes),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return AutomationPrincipalResolution(
|
||||||
|
allowed=True,
|
||||||
|
principal=api_principal,
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
provenance=provenance,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_denied(
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
reason: str,
|
||||||
|
granted_scopes: tuple[str, ...] = (),
|
||||||
|
missing_scopes: tuple[str, ...] | None = None,
|
||||||
|
) -> AutomationPrincipalResolution:
|
||||||
|
missing = (
|
||||||
|
tuple(sorted(set(request.grant_scopes)))
|
||||||
|
if missing_scopes is None
|
||||||
|
else missing_scopes
|
||||||
|
)
|
||||||
|
return AutomationPrincipalResolution(
|
||||||
|
allowed=False,
|
||||||
|
reason=reason,
|
||||||
|
granted_scopes=granted_scopes,
|
||||||
|
missing_scopes=missing,
|
||||||
|
provenance=_automation_provenance(
|
||||||
|
request,
|
||||||
|
status=status,
|
||||||
|
current_principal=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_provenance(
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
current_principal: Mapping[str, object] | None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"contract_version": request.contract_version,
|
||||||
|
"authorization_artifact": {
|
||||||
|
"ref": request.authorization_ref,
|
||||||
|
},
|
||||||
|
"trigger_owner": _automation_trigger_owner(request),
|
||||||
|
"current_automation_principal": (
|
||||||
|
dict(current_principal)
|
||||||
|
if current_principal is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"event_actor": _automation_context_actor(
|
||||||
|
request.context.get("event_actor")
|
||||||
|
),
|
||||||
|
"operator_override": _automation_context_actor(
|
||||||
|
request.context.get("operator_override")
|
||||||
|
),
|
||||||
|
"trigger_ref": _optional_context_text(
|
||||||
|
request.context.get("trigger_ref")
|
||||||
|
),
|
||||||
|
"delivery_ref": _optional_context_text(
|
||||||
|
request.context.get("delivery_ref")
|
||||||
|
),
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_trigger_owner(
|
||||||
|
request: AutomationPrincipalRequest,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"kind": request.subject_kind,
|
||||||
|
"tenant_id": request.tenant_id,
|
||||||
|
"account_id": request.account_id,
|
||||||
|
"membership_id": request.membership_id,
|
||||||
|
"service_account_id": request.service_account_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _automation_context_actor(
|
||||||
|
value: object,
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
return None
|
||||||
|
result = {
|
||||||
|
key: str(value[key]).strip()
|
||||||
|
for key in (
|
||||||
|
"kind",
|
||||||
|
"type",
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"account_id",
|
||||||
|
"membership_id",
|
||||||
|
"service_account_id",
|
||||||
|
"reason",
|
||||||
|
)
|
||||||
|
if value.get(key) is not None
|
||||||
|
and str(value[key]).strip()
|
||||||
|
}
|
||||||
|
return result or None
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_context_text(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
clean = str(value).strip()
|
||||||
|
return clean[:300] or None
|
||||||
|
|
||||||
|
|
||||||
def get_api_principal(
|
def get_api_principal(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
@@ -15,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,
|
||||||
@@ -41,6 +42,8 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH
|
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:
|
||||||
@@ -71,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"),
|
||||||
@@ -89,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"),
|
||||||
@@ -125,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",
|
||||||
),
|
),
|
||||||
@@ -183,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",
|
||||||
),
|
),
|
||||||
@@ -233,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",
|
||||||
@@ -244,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, ...] = (
|
||||||
@@ -479,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
|
||||||
@@ -506,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
|
||||||
@@ -592,10 +634,14 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -612,6 +658,14 @@ manifest = ModuleManifest(
|
|||||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version="0.1.0"),
|
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,
|
||||||
@@ -629,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,
|
||||||
@@ -650,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,
|
||||||
@@ -665,6 +739,7 @@ manifest = ModuleManifest(
|
|||||||
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_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")
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
from govoplan_access.backend.auth.tokens import generate_secret, hash_secret, verify_secret
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
@@ -104,17 +104,39 @@ def create_auth_session(
|
|||||||
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
|
return CreatedSession(model=model, token=token, csrf_token=csrf_token)
|
||||||
|
|
||||||
|
|
||||||
def authenticate_session_token(session: Session, token: str) -> AuthSession | None:
|
def authenticate_session_token(
|
||||||
|
session: Session,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
touch_interval_seconds: int = 5 * 60,
|
||||||
|
) -> AuthSession | None:
|
||||||
token_hash = hash_session_token(token)
|
token_hash = hash_session_token(token)
|
||||||
model = session.query(AuthSession).filter(AuthSession.token_hash == token_hash, AuthSession.revoked_at.is_(None)).one_or_none()
|
model = (
|
||||||
|
session.query(AuthSession)
|
||||||
|
.options(
|
||||||
|
joinedload(AuthSession.user).joinedload(User.account),
|
||||||
|
joinedload(AuthSession.account),
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
AuthSession.token_hash == token_hash,
|
||||||
|
AuthSession.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
if not model:
|
if not model:
|
||||||
return None
|
return None
|
||||||
now = utc_now()
|
now = utc_now()
|
||||||
expires_at = ensure_aware_utc(model.expires_at)
|
expires_at = ensure_aware_utc(model.expires_at)
|
||||||
if expires_at is None or expires_at < now:
|
if expires_at is None or expires_at < now:
|
||||||
return None
|
return None
|
||||||
model.last_seen_at = now
|
last_seen_at = ensure_aware_utc(model.last_seen_at)
|
||||||
session.add(model)
|
if (
|
||||||
|
touch_interval_seconds <= 0
|
||||||
|
or last_seen_at is None
|
||||||
|
or now - last_seen_at >= timedelta(seconds=touch_interval_seconds)
|
||||||
|
):
|
||||||
|
model.last_seen_at = now
|
||||||
|
session.add(model)
|
||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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",
|
||||||
|
]
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, event
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.administration import SqlAccessAdministration
|
||||||
from govoplan_access.backend.api.v1.admin_common import (
|
from govoplan_access.backend.api.v1.admin_common import (
|
||||||
_accounts_by_user_id,
|
_accounts_by_user_id,
|
||||||
_group_member_ids_by_group_id,
|
_group_member_ids_by_group_id,
|
||||||
@@ -13,7 +15,7 @@ from govoplan_access.backend.api.v1.admin_common import (
|
|||||||
_roles_by_user_id,
|
_roles_by_user_id,
|
||||||
_tenant_role_assignment_counts,
|
_tenant_role_assignment_counts,
|
||||||
)
|
)
|
||||||
from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
from govoplan_access.backend.db.models import Account, ApiKey, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +58,92 @@ class AdminBatchHelperTests(unittest.TestCase):
|
|||||||
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
self.assertEqual([item.id for item in roles_by_user[user.id]], [role.id])
|
||||||
self.assertEqual(role_counts, {role.id: (1, 1)})
|
self.assertEqual(role_counts, {role.id: (1, 1)})
|
||||||
|
|
||||||
|
def test_tenant_counts_many_uses_three_grouped_queries(self) -> None:
|
||||||
|
accounts = [
|
||||||
|
Account(
|
||||||
|
id=f"account-{index}",
|
||||||
|
email=f"user-{index}@example.test",
|
||||||
|
normalized_email=f"user-{index}@example.test",
|
||||||
|
)
|
||||||
|
for index in range(3)
|
||||||
|
]
|
||||||
|
users = [
|
||||||
|
User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=accounts[0].id,
|
||||||
|
email=accounts[0].email,
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id="user-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=accounts[1].id,
|
||||||
|
email=accounts[1].email,
|
||||||
|
is_active=False,
|
||||||
|
),
|
||||||
|
User(
|
||||||
|
id="user-3",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
account_id=accounts[2].id,
|
||||||
|
email=accounts[2].email,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
*accounts,
|
||||||
|
*users,
|
||||||
|
Group(id="group-1", tenant_id="tenant-1", slug="one", name="One"),
|
||||||
|
Group(id="group-2", tenant_id="tenant-2", slug="two", name="Two"),
|
||||||
|
ApiKey(
|
||||||
|
id="key-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-1",
|
||||||
|
name="Active",
|
||||||
|
prefix="active",
|
||||||
|
key_hash="hash-1",
|
||||||
|
),
|
||||||
|
ApiKey(
|
||||||
|
id="key-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="user-2",
|
||||||
|
name="Revoked",
|
||||||
|
prefix="revoked",
|
||||||
|
key_hash="hash-2",
|
||||||
|
revoked_at=datetime.now(UTC),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
query_count = 0
|
||||||
|
|
||||||
|
def count_query(*_args: object) -> None:
|
||||||
|
nonlocal query_count
|
||||||
|
query_count += 1
|
||||||
|
|
||||||
|
event.listen(self.engine, "before_cursor_execute", count_query)
|
||||||
|
try:
|
||||||
|
counts = SqlAccessAdministration().tenant_counts_many(
|
||||||
|
self.session,
|
||||||
|
["tenant-1", "tenant-2", "tenant-empty"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
event.remove(self.engine, "before_cursor_execute", count_query)
|
||||||
|
|
||||||
|
self.assertEqual(3, query_count)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"users": 2,
|
||||||
|
"active_users": 1,
|
||||||
|
"groups": 1,
|
||||||
|
"api_keys": 2,
|
||||||
|
"active_api_keys": 1,
|
||||||
|
},
|
||||||
|
counts["tenant-1"],
|
||||||
|
)
|
||||||
|
self.assertEqual(1, counts["tenant-2"]["users"])
|
||||||
|
self.assertEqual(0, counts["tenant-empty"]["users"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
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()
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from govoplan_access.backend.auth.dependencies import _extract_token, _requires_csrf, _resolve_legacy_principal_ref
|
from govoplan_access.backend.auth.dependencies import (
|
||||||
|
_extract_token,
|
||||||
|
_requires_csrf,
|
||||||
|
_resolve_legacy_principal_context,
|
||||||
|
_resolve_legacy_principal_ref,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.auth.principal_cache import principal_summary_cache
|
||||||
|
from govoplan_access.backend.auth.tokens import hash_secret
|
||||||
|
from govoplan_access.backend.db.base import AccessBase
|
||||||
|
from govoplan_access.backend.db.models import Account, AuthSession, Role, User, UserRoleAssignment
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||||
|
from govoplan_core.core.principal_cache import invalidate_auth_principals
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
from govoplan_core.settings import settings
|
from govoplan_core.settings import settings
|
||||||
|
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
|
||||||
|
|
||||||
|
|
||||||
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ()) -> Request:
|
||||||
@@ -22,6 +39,9 @@ def request_for(*, method: str = "GET", headers: Iterable[tuple[str, str]] = ())
|
|||||||
|
|
||||||
|
|
||||||
class AuthDependencyTests(unittest.TestCase):
|
class AuthDependencyTests(unittest.TestCase):
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
principal_summary_cache.clear()
|
||||||
|
|
||||||
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
def test_extract_token_prefers_explicit_api_key(self) -> None:
|
||||||
request = request_for(headers=[("authorization", "Bearer session-token")])
|
request = request_for(headers=[("authorization", "Bearer session-token")])
|
||||||
|
|
||||||
@@ -44,6 +64,98 @@ class AuthDependencyTests(unittest.TestCase):
|
|||||||
self.assertEqual(raised.exception.status_code, 401)
|
self.assertEqual(raised.exception.status_code, 401)
|
||||||
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
self.assertEqual(raised.exception.detail, "Missing API key or session token")
|
||||||
|
|
||||||
|
def test_permission_revision_invalidates_cached_principal(self) -> None:
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
create_scope_tables(engine)
|
||||||
|
AccessBase.metadata.create_all(bind=engine)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
bind=engine,
|
||||||
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
ChangeSequenceRetentionFloor.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
try:
|
||||||
|
with SessionLocal() as session:
|
||||||
|
tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
|
||||||
|
account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="owner@example.test",
|
||||||
|
normalized_email="owner@example.test",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id="user-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
account_id=account.id,
|
||||||
|
email=account.email,
|
||||||
|
)
|
||||||
|
role = Role(
|
||||||
|
id="role-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
slug="reader",
|
||||||
|
name="Reader",
|
||||||
|
permissions=["files:file:read"],
|
||||||
|
)
|
||||||
|
assignment = UserRoleAssignment(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
role_id=role.id,
|
||||||
|
)
|
||||||
|
token = "ms_test-session-token"
|
||||||
|
auth_session = AuthSession(
|
||||||
|
id="session-1",
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
user_id=user.id,
|
||||||
|
account_id=account.id,
|
||||||
|
token_hash=hash_secret(token),
|
||||||
|
expires_at=utc_now() + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[tenant, account, user, role, assignment, auth_session]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
request = request_for()
|
||||||
|
first = _resolve_legacy_principal_context(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
authorization=f"Bearer {token}",
|
||||||
|
x_api_key=None,
|
||||||
|
)
|
||||||
|
self.assertIn("files:file:read", first.principal.scopes)
|
||||||
|
|
||||||
|
role.permissions = ["files:file:write"]
|
||||||
|
session.add(role)
|
||||||
|
invalidate_auth_principals(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
source_module="access",
|
||||||
|
resource_type="role",
|
||||||
|
resource_id=role.id,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
second = _resolve_legacy_principal_context(
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
authorization=f"Bearer {token}",
|
||||||
|
x_api_key=None,
|
||||||
|
)
|
||||||
|
self.assertNotIn("files:file:read", second.principal.scopes)
|
||||||
|
self.assertIn("files:file:write", second.principal.scopes)
|
||||||
|
finally:
|
||||||
|
AccessBase.metadata.drop_all(bind=engine)
|
||||||
|
scope_registry.metadata.drop_all(bind=engine)
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
bind=engine,
|
||||||
|
tables=[
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
ChangeSequenceRetentionFloor.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
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()
|
||||||
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()
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"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": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { fetchShellAuth } from "@govoplan/core-webui";
|
import { fetchShellAuth } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
|
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||||
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
import { adminReadScopes, hasAnyScope, hasScope } from "@govoplan/core-webui";
|
||||||
import SystemUsersPanel from "./SystemUsersPanel";
|
import SystemUsersPanel from "./SystemUsersPanel";
|
||||||
@@ -25,7 +26,14 @@ 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 };
|
||||||
@@ -43,6 +51,7 @@ const handledAdminSectionIds = new Set<string>([
|
|||||||
"system-users",
|
"system-users",
|
||||||
"system-file-connectors",
|
"system-file-connectors",
|
||||||
"system-mail-servers",
|
"system-mail-servers",
|
||||||
|
"system-credentials",
|
||||||
"tenant-settings",
|
"tenant-settings",
|
||||||
"tenant-roles",
|
"tenant-roles",
|
||||||
"tenant-function-role-mappings",
|
"tenant-function-role-mappings",
|
||||||
@@ -50,13 +59,38 @@ 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"
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminPage({
|
export default function AdminPage({
|
||||||
settings,
|
settings,
|
||||||
auth,
|
auth,
|
||||||
@@ -70,14 +104,23 @@ export default function AdminPage({
|
|||||||
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
const fileConnectorsUi = usePlatformUiCapability<FilesConnectorsUiCapability>("files.connectors");
|
||||||
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
const organizationFunctionPicker = usePlatformUiCapability<OrganizationFunctionPickerUiCapability>("organizations.functionPicker");
|
||||||
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
const adminSectionCapabilities = usePlatformUiCapabilities<AdminSectionsUiCapability>("admin.sections");
|
||||||
|
const effectiveView = useEffectiveView();
|
||||||
|
const viewSurfaces = useViewSurfaces();
|
||||||
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
const mailProfilesAvailable = Boolean(mailProfilesUi);
|
||||||
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
const fileConnectorsAvailable = Boolean(fileConnectorsUi);
|
||||||
const contributedSections = useMemo(
|
const contributedSections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
adminSectionCapabilities
|
adminSectionCapabilities
|
||||||
.flatMap((capability) => capability.sections)
|
.flatMap((capability) => capability.sections)
|
||||||
|
.filter((section) =>
|
||||||
|
isViewSurfaceVisible(
|
||||||
|
effectiveView,
|
||||||
|
section.surfaceId,
|
||||||
|
viewSurfaces
|
||||||
|
)
|
||||||
|
)
|
||||||
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
.sort((left, right) => (left.order ?? 100) - (right.order ?? 100)),
|
||||||
[adminSectionCapabilities]
|
[adminSectionCapabilities, effectiveView, viewSurfaces]
|
||||||
);
|
);
|
||||||
const contributionById = useMemo(() => {
|
const contributionById = useMemo(() => {
|
||||||
const mapped = new Map<string, AdminSectionContribution>();
|
const mapped = new Map<string, AdminSectionContribution>();
|
||||||
@@ -95,6 +138,9 @@ export default function AdminPage({
|
|||||||
if (hasScope(auth, "system:settings:read")) {
|
if (hasScope(auth, "system:settings:read")) {
|
||||||
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
if (mailProfilesAvailable) sections.add("system-mail-servers");
|
||||||
}
|
}
|
||||||
|
if (hasAnyScope(auth, ["system:settings:read", "access:system_credential:read"])) {
|
||||||
|
sections.add("system-credentials");
|
||||||
|
}
|
||||||
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
if (hasScope(auth, "system:tenants:read")) sections.add("system-tenants");
|
||||||
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
if (hasAnyScope(auth, ["system:accounts:read", "system:access:read"])) sections.add("system-users");
|
||||||
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
if (hasAnyScope(auth, ["system:roles:read", "system:access:read"])) sections.add("system-roles");
|
||||||
@@ -113,8 +159,21 @@ export default function AdminPage({
|
|||||||
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-file-connectors");
|
||||||
}
|
}
|
||||||
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
if (hasScope(auth, "admin:settings:read")) sections.add("tenant-settings");
|
||||||
return sections;
|
if (hasAnyScope(auth, ["admin:settings:read", "access:credential:read"])) {
|
||||||
}, [auth, contributedSections, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker]);
|
sections.add("tenant-credentials");
|
||||||
|
if (hasScope(auth, "admin:users:read")) sections.add("tenant-user-credentials");
|
||||||
|
if (hasScope(auth, "admin:groups:read")) sections.add("tenant-group-credentials");
|
||||||
|
}
|
||||||
|
return new Set(
|
||||||
|
[...sections].filter((sectionId) =>
|
||||||
|
isViewSurfaceVisible(
|
||||||
|
effectiveView,
|
||||||
|
builtInAdminSurfaceIds[sectionId],
|
||||||
|
viewSurfaces
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [auth, contributedSections, effectiveView, fileConnectorsAvailable, mailProfilesAvailable, organizationFunctionPicker, viewSurfaces]);
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const requestedSection = searchParams.get("section") as AdminSection | null;
|
const requestedSection = searchParams.get("section") as AdminSection | null;
|
||||||
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
const fallbackSection = available.has("overview") ? "overview" : (Array.from(available)[0] ?? "overview");
|
||||||
@@ -137,11 +196,13 @@ export default function AdminPage({
|
|||||||
|
|
||||||
if (!hasAnyScope(auth, adminReadScopes)) {
|
if (!hasAnyScope(auth, adminReadScopes)) {
|
||||||
return (
|
return (
|
||||||
|
<PageScrollViewport>
|
||||||
<div className="content-pad">
|
<div className="content-pad">
|
||||||
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
|
<Card title="i18n:govoplan-access.administration_unavailable.b86d4cb5">
|
||||||
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
|
<p>i18n:govoplan-access.your_current_roles_do_not_grant_administrative_a.6eafee69</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</PageScrollViewport>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +231,7 @@ 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)
|
||||||
])
|
])
|
||||||
@@ -185,7 +247,8 @@ export default function AdminPage({
|
|||||||
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)
|
||||||
])
|
])
|
||||||
@@ -197,6 +260,7 @@ export default function AdminPage({
|
|||||||
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)
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
@@ -207,6 +271,7 @@ export default function AdminPage({
|
|||||||
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)
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
@@ -224,6 +289,13 @@ export default function AdminPage({
|
|||||||
{!contributedSection && active === "system-mail-servers" && (
|
{!contributedSection && active === "system-mail-servers" && (
|
||||||
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
<MailProfilesPanel settings={settings} scopeType="system" canWriteProfiles={hasScope(auth, "system:settings:write")} canManageCredentials={hasScope(auth, "system:settings:write")} canWritePolicy={hasScope(auth, "system:settings:write")} />
|
||||||
)}
|
)}
|
||||||
|
{!contributedSection && active === "system-credentials" && (
|
||||||
|
<CredentialEnvelopesPanel
|
||||||
|
settings={settings}
|
||||||
|
scopeType="system"
|
||||||
|
canWrite={hasAnyScope(auth, ["system:settings:write", "access:system_credential:write"])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{!contributedSection && active === "system-tenants" && (
|
{!contributedSection && active === "system-tenants" && (
|
||||||
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
|
<TenantsPanel settings={settings} auth={auth} canCreate={hasScope(auth, "system:tenants:create")} canUpdate={hasScope(auth, "system:tenants:update")} canSuspend={hasScope(auth, "system:tenants:suspend")} onAuthRefresh={refreshAuth} />
|
||||||
)}
|
)}
|
||||||
@@ -245,8 +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-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} />}
|
||||||
|
|||||||
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
|
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 }],
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user