13 Commits

34 changed files with 4216 additions and 117 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.8", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -18,7 +18,7 @@
"LICENSE" "LICENSE"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.9", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ def ensure_default_roles(session: Session, tenant: Tenant | None = None) -> dict
query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None)) query = query.filter(Role.tenant_id == tenant.id) if tenant is not None else query.filter(Role.tenant_id.is_(None))
role = query.one_or_none() role = query.one_or_none()
is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None) is_builtin = _template_is_builtin(template_managed=template.managed, protected=template.protected, tenant_role=tenant is not None)
is_assignable = True is_assignable = not template.default_authenticated
if role is None: if role is None:
role = Role( role = Role(
tenant_id=tenant.id if tenant is not None else None, tenant_id=tenant.id if tenant is not None else None,
@@ -200,7 +200,24 @@ def set_user_groups(session: Session, *, user: User, group_ids: Iterable[str]) -
def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None: def set_user_roles(session: Session, *, user: User, role_ids: Iterable[str]) -> None:
ids = sorted(set(role_ids)) default_slugs = {
template.slug
for template in role_templates_for_level("tenant")
if template.default_authenticated
}
default_roles = (
session.query(Role)
.filter(
Role.tenant_id == user.tenant_id,
Role.slug.in_(default_slugs),
)
.all()
if default_slugs
else []
)
default_role_ids = {role.id for role in default_roles}
requested_ids = set(role_ids) - default_role_ids
ids = sorted(requested_ids)
roles = ( roles = (
session.query(Role) session.query(Role)
.filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True)) .filter(Role.tenant_id == user.tenant_id, Role.id.in_(ids), Role.is_assignable.is_(True))

View File

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

View File

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

View File

@@ -193,7 +193,7 @@ class OrganizationUnitItem(BaseModel):
updated_at: datetime updated_at: datetime
class OrganizationUnitListResponse(BaseModel): class OrganizationUnitListResponse(PagedListResponse):
organization_units: list[OrganizationUnitItem] organization_units: list[OrganizationUnitItem]
@@ -232,7 +232,7 @@ class FunctionAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionListResponse(BaseModel): class FunctionListResponse(PagedListResponse):
functions: list[FunctionAdminItem] functions: list[FunctionAdminItem]
@@ -247,7 +247,7 @@ class ExternalFunctionRoleMappingItem(BaseModel):
updated_at: datetime updated_at: datetime
class ExternalFunctionRoleMappingListResponse(BaseModel): class ExternalFunctionRoleMappingListResponse(PagedListResponse):
mappings: list[ExternalFunctionRoleMappingItem] mappings: list[ExternalFunctionRoleMappingItem]
@@ -318,7 +318,7 @@ class FunctionAssignmentAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionAssignmentListResponse(BaseModel): class FunctionAssignmentListResponse(PagedListResponse):
assignments: list[FunctionAssignmentAdminItem] assignments: list[FunctionAssignmentAdminItem]
@@ -368,7 +368,7 @@ class FunctionDelegationAdminItem(BaseModel):
updated_at: datetime updated_at: datetime
class FunctionDelegationListResponse(BaseModel): class FunctionDelegationListResponse(PagedListResponse):
delegations: list[FunctionDelegationAdminItem] delegations: list[FunctionDelegationAdminItem]
@@ -876,6 +876,55 @@ class AdminApiKeyCreateResponse(ApiKeyAdminItem):
secret: str secret: str
class ServiceAccountItem(BaseModel):
id: str
tenant_id: str
name: str
description: str | None = None
scope_ceiling: list[str] = Field(default_factory=list)
is_active: bool
revision: int
created_by_account_id: str | None = None
updated_by_account_id: str | None = None
retired_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ServiceAccountListResponse(BaseModel):
items: list[ServiceAccountItem]
class ServiceAccountCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
scope_ceiling: list[str] = Field(
default_factory=list,
max_length=200,
)
class ServiceAccountUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = Field(default=None, max_length=4000)
scope_ceiling: list[str] | None = Field(
default=None,
max_length=200,
)
is_active: bool | None = None
class ServiceAccountRetireRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
class AuditAdminItem(BaseModel): class AuditAdminItem(BaseModel):
id: str id: str
scope: Literal["tenant", "system"] = "tenant" scope: Literal["tenant", "system"] = "tenant"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from govoplan_access.backend.db.models import Account, User
from govoplan_core.core.people import (
PeopleSearchError,
PeopleSearchGroup,
PersonSearchCandidate,
person_selection_key,
)
def _principal_tenant_id(principal: object) -> str:
try:
tenant_id = getattr(principal, "tenant_id")
except (AttributeError, RuntimeError) as exc:
raise PeopleSearchError("People search requires an active tenant context.") from exc
normalized = str(tenant_id or "").strip()
if not normalized:
raise PeopleSearchError("People search requires an active tenant context.")
return normalized
def _like_pattern(query: str) -> str:
escaped = query.casefold().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
class AccessPeopleSearchProvider:
"""Search active accounts through their active tenant membership.
The active principal's tenant is the only accepted visibility boundary;
global accounts and memberships of other tenants are never candidates.
Feature routers authorize the surrounding task before invoking this
capability.
"""
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> tuple[PeopleSearchGroup, ...]:
if not isinstance(session, Session):
raise PeopleSearchError("People search requires a database session.")
tenant_id = _principal_tenant_id(principal)
normalized_limit = max(1, min(int(limit), 100))
account_query = (
session.query(User, Account)
.join(Account, Account.id == User.account_id)
.filter(
User.tenant_id == tenant_id,
User.is_active.is_(True),
Account.is_active.is_(True),
)
)
normalized_query = str(query or "").strip()
if normalized_query:
pattern = _like_pattern(normalized_query)
account_query = account_query.filter(
or_(
func.lower(User.display_name).like(pattern, escape="\\"),
func.lower(User.email).like(pattern, escape="\\"),
func.lower(Account.display_name).like(pattern, escape="\\"),
func.lower(Account.email).like(pattern, escape="\\"),
)
)
rows = (
account_query
.order_by(
func.coalesce(User.display_name, Account.display_name, User.email, Account.email).asc(),
Account.id.asc(),
)
.limit(normalized_limit)
.all()
)
candidates = tuple(
PersonSearchCandidate(
selection_key=person_selection_key("account", account.id),
kind="account",
reference_id=account.id,
display_name=user.display_name or account.display_name or user.email or account.email,
email=user.email or account.email,
source_module="access",
source_label="Accounts",
source_ref=f"access:account:{account.id}",
)
for user, account in rows
)
return (PeopleSearchGroup(key="accounts", label="Accounts", candidates=candidates),)
def people_search_capability(_context: object) -> AccessPeopleSearchProvider:
return AccessPeopleSearchProvider()
__all__ = ["AccessPeopleSearchProvider", "people_search_capability"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.administration import SqlAccessAdministration
from govoplan_access.backend.api.v1.admin_common import ( from govoplan_access.backend.api.v1.admin_common import (
_accounts_by_user_id, _accounts_by_user_id,
_group_member_ids_by_group_id, _group_member_ids_by_group_id,
@@ -13,7 +15,7 @@ from govoplan_access.backend.api.v1.admin_common import (
_roles_by_user_id, _roles_by_user_id,
_tenant_role_assignment_counts, _tenant_role_assignment_counts,
) )
from govoplan_access.backend.db.models import Account, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment from govoplan_access.backend.db.models import Account, ApiKey, Group, GroupRoleAssignment, Role, User, UserGroupMembership, UserRoleAssignment
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -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()

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.admin.service import ensure_default_roles, set_user_roles
from govoplan_access.backend.db.models import Account, Role, User, UserRoleAssignment
from govoplan_access.backend.security.sessions import collect_user_authorization_context
from govoplan_core.core.modules import RoleTemplate
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import Tenant, create_scope_tables, scope_registry
DOCS_READER = RoleTemplate(
slug="docs_reader",
name="Documentation reader",
description="Authenticated documentation baseline.",
permissions=("docs:documentation:read",),
default_authenticated=True,
)
class DefaultAuthenticatedRoleTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.tenant = Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1")
self.session.add(self.tenant)
self.session.flush()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
scope_registry.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _add_user(self, suffix: str) -> User:
account = Account(
id=f"account-{suffix}",
email=f"{suffix}@example.test",
normalized_email=f"{suffix}@example.test",
)
user = User(
id=f"user-{suffix}",
tenant_id=self.tenant.id,
account_id=account.id,
email=account.email,
)
self.session.add_all([account, user])
self.session.flush()
return user
def test_materialized_default_role_is_implicit_and_cannot_be_assigned_or_removed(self) -> None:
first = self._add_user("first")
self._add_user("second")
with (
patch(
"govoplan_access.backend.admin.service.role_templates_for_level",
return_value=(DOCS_READER,),
),
patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
),
):
roles = ensure_default_roles(self.session, self.tenant)
default_role = roles["docs_reader"]
self.assertFalse(default_role.is_assignable)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
# The database row is only an administration projection. Even if
# stale or tampered, it must never broaden the manifest baseline.
default_role.permissions = ["mail:profile:write"]
self.session.flush()
set_user_roles(self.session, user=first, role_ids=[])
context = collect_user_authorization_context(
self.session,
first,
account=first.account,
include_system=False,
)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
self.assertIn("docs:documentation:read", context.scopes)
self.assertNotIn("mail:profile:write", context.scopes)
self.assertEqual([role.slug for role in context.tenant_roles], ["docs_reader"])
def test_first_authorized_request_grants_default_without_database_mutation(self) -> None:
user = self._add_user("new")
with patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
):
context = collect_user_authorization_context(
self.session,
user,
account=user.account,
include_system=False,
)
self.assertIn("docs:documentation:read", context.scopes)
self.assertEqual(context.tenant_roles, [])
self.assertEqual(self.session.query(Role).count(), 0)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
self.assertFalse(self.session.new)
self.assertFalse(self.session.dirty)
user_id = user.id
self.session.commit()
self.session.close()
self.session = self.Session()
persisted_user = self.session.get(User, user_id)
with patch(
"govoplan_access.backend.security.sessions.role_templates_for_level",
return_value=(DOCS_READER,),
):
reopened_context = collect_user_authorization_context(
self.session,
persisted_user,
account=persisted_user.account,
include_system=False,
)
self.assertIn("docs:documentation:read", reopened_context.scopes)
self.assertEqual(self.session.query(Role).count(), 0)
self.assertEqual(self.session.query(UserRoleAssignment).count(), 0)
if __name__ == "__main__":
unittest.main()

View File

@@ -15,7 +15,7 @@ class OptionalTenancyContractTests(unittest.TestCase):
dependencies = tuple(project["dependencies"]) dependencies = tuple(project["dependencies"])
self.assertIn("govoplan-core>=0.1.9", dependencies) self.assertIn("govoplan-core>=0.1.11", dependencies)
self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies) self.assertNotIn("govoplan-tenancy>=0.1.8", dependencies)
self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies)) self.assertFalse(any(item.startswith("govoplan-tenancy") for item in dependencies))

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, User
from govoplan_access.backend.manifest import manifest
from govoplan_access.backend.people_search import AccessPeopleSearchProvider
from govoplan_core.core.people import CAPABILITY_ACCESS_PEOPLE_SEARCH, PeopleSearchError, PeopleSearchProvider
from govoplan_core.db.base import Base
class AccessPeopleSearchTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=self.engine)
self.Session = sessionmaker(bind=self.engine)
self.session = self.Session()
self.provider = AccessPeopleSearchProvider()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(bind=self.engine)
self.engine.dispose()
def _add_user(
self,
suffix: str,
*,
tenant_id: str = "tenant-1",
display_name: str | None = None,
user_active: bool = True,
account_active: bool = True,
) -> None:
email = f"{suffix}@example.test"
account = Account(
id=f"account-{suffix}",
email=email,
normalized_email=email,
display_name=display_name,
is_active=account_active,
)
self.session.add_all([
account,
User(
id=f"user-{suffix}",
tenant_id=tenant_id,
account_id=account.id,
email=email,
display_name=display_name,
is_active=user_active,
),
])
self.session.flush()
def test_search_is_tenant_bounded_and_excludes_inactive_records(self) -> None:
self._add_user("ada", display_name="Ada Lovelace")
self._add_user("other", tenant_id="tenant-2", display_name="Ada Other Tenant")
self._add_user("inactive-user", display_name="Ada Inactive User", user_active=False)
self._add_user("inactive-account", display_name="Ada Inactive Account", account_active=False)
groups = self.provider.search_people(
self.session,
SimpleNamespace(tenant_id="tenant-1"),
query="ada",
)
self.assertEqual([group.key for group in groups], ["accounts"])
self.assertEqual([item.reference_id for item in groups[0].candidates], ["account-ada"])
self.assertEqual(groups[0].candidates[0].email, "ada@example.test")
self.assertNotIn("tenant_id", groups[0].candidates[0].metadata)
def test_search_escapes_like_wildcards_and_requires_tenant_context(self) -> None:
self._add_user("ada", display_name="Ada Lovelace")
groups = self.provider.search_people(
self.session,
SimpleNamespace(tenant_id="tenant-1"),
query="%",
)
self.assertEqual(groups[0].candidates, ())
with self.assertRaises(PeopleSearchError):
self.provider.search_people(self.session, SimpleNamespace(tenant_id=None), query="ada")
def test_manifest_exposes_the_shared_interface(self) -> None:
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, manifest.capability_factories)
self.assertIn(CAPABILITY_ACCESS_PEOPLE_SEARCH, {item.name for item in manifest.provides_interfaces})
self.assertIsInstance(self.provider, PeopleSearchProvider)
if __name__ == "__main__":
unittest.main()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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