Move access permissions to access module

This commit is contained in:
2026-07-10 23:27:48 +02:00
parent a7c486788e
commit e32841077c
13 changed files with 1319 additions and 170 deletions

View File

@@ -25,7 +25,11 @@ from govoplan_access.backend.security.sessions import (
collect_user_groups,
collect_user_scopes,
)
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids
from govoplan_access.backend.semantic import (
collect_external_function_roles,
collect_function_assignment_ids,
collect_function_delegation_ids,
)
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
from govoplan_access.backend.db.models import (
Account,
@@ -38,7 +42,8 @@ from govoplan_access.backend.db.models import (
User,
UserGroupMembership,
)
from govoplan_core.security.permissions import effective_permission_count
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
def _http_admin_error(exc: Exception) -> HTTPException:
@@ -132,12 +137,24 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
)
def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = None) -> UserAdminItem:
def _user_item(
session: Session,
user: User,
*,
owner_ids: set[str] | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
) -> UserAdminItem:
account = session.get(Account, user.account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = collect_user_groups(session, user)
roles = collect_direct_user_roles(session, user)
external_roles = collect_external_function_roles(session, user, idm_assignments) if idm_assignments else []
effective_scopes = set(collect_user_scopes(session, user, include_system=False))
for role in external_roles:
effective_scopes.update(role.permissions or [])
function_assignment_ids = collect_function_assignment_ids(session, user)
function_assignment_ids.extend(item.id for item in idm_assignments)
effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
return UserAdminItem(
id=user.id,
@@ -151,9 +168,9 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
last_login_at=account.last_login_at,
groups=[_group_summary(session, group, include_members=False) for group in groups],
roles=[_role_summary(session, role) for role in roles],
function_assignment_ids=collect_function_assignment_ids(session, user),
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
function_delegation_ids=collect_function_delegation_ids(session, user),
effective_scopes=collect_user_scopes(session, user, include_system=False),
effective_scopes=expand_scopes(effective_scopes),
is_owner=user.id in effective_owner_ids,
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
created_at=user.created_at,

View File

@@ -454,6 +454,64 @@ class UserAdminItem(BaseModel):
updated_at: datetime
AccessRoleSourceType = Literal["direct_role", "group_role", "legacy_function_role", "idm_function_role", "system_role"]
class AccessRoleSourceItem(BaseModel):
source_type: AccessRoleSourceType
role_id: str
role_slug: str
role_name: str
permissions: list[str] = Field(default_factory=list)
tenant_id: str | None = None
group_id: str | None = None
group_name: str | None = None
function_assignment_id: str | None = None
function_id: str | None = None
function_name: str | None = None
organization_unit_id: str | None = None
organization_unit_name: str | None = None
identity_id: str | None = None
account_id: str | None = None
source_module: str | None = None
assignment_source: str | None = None
applies_to_subunits: bool = False
delegated_from_assignment_id: str | None = None
delegation_id: str | None = None
acting_for_account_id: str | None = None
class AccessScopeExplanationItem(BaseModel):
scope: str
sources: list[AccessRoleSourceItem] = Field(default_factory=list)
class FunctionFactExplanationItem(BaseModel):
source_module: str
assignment_id: str
tenant_id: str
identity_id: str | None = None
account_id: str | None = None
function_id: str
function_name: str | None = None
organization_unit_id: str
organization_unit_name: str | None = None
applies_to_subunits: bool = False
assignment_source: str
status: str
delegated_from_assignment_id: str | None = None
acting_for_account_id: str | None = None
role_ids: list[str] = Field(default_factory=list)
role_names: list[str] = Field(default_factory=list)
class UserAccessExplanationResponse(BaseModel):
user: UserAdminItem
role_sources: list[AccessRoleSourceItem] = Field(default_factory=list)
scopes: list[AccessScopeExplanationItem] = Field(default_factory=list)
function_facts: list[FunctionFactExplanationItem] = Field(default_factory=list)
class UserListResponse(BaseModel):
users: list[UserAdminItem]

View File

@@ -34,7 +34,7 @@ from govoplan_core.i18n import (
tenant_enabled_language_codes,
user_enabled_language_codes,
)
from govoplan_core.security.permissions import normalize_email, scopes_grant
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant
from govoplan_core.security.time import utc_now
from govoplan_core.settings import settings
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account

View File

@@ -120,16 +120,17 @@ from govoplan_access.backend.api.v1.admin_schemas import (
SystemAccountUpdateRequest,
UserCreateRequest,
UserCreateResponse,
UserAccessExplanationResponse,
UserAdminItem,
UserListDeltaResponse,
UserListResponse,
UserUpdateRequest,
)
from govoplan_access.backend.security.api_keys import create_api_key
from govoplan_access.backend.security.sessions import collect_user_scopes
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
from govoplan_core.audit.logging import audit_event, audit_from_principal
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
from govoplan_access.backend.explanation import build_user_access_explanation
from govoplan_access.backend.runtime import get_registry
from govoplan_core.core.configuration_packages import (
CONFIGURATION_PROVIDER_CAPABILITY,
@@ -153,6 +154,7 @@ from govoplan_core.core.configuration_control import (
record_configuration_change_applied,
)
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_core.core.change_sequence import (
@@ -185,7 +187,11 @@ from govoplan_access.backend.db.models import (
)
from govoplan_access.backend.semantic import identity_id_for_account
from govoplan_core.db.session import get_session
from govoplan_core.security.permissions import ALL_PERMISSIONS, normalize_email, scopes_grant
from govoplan_access.backend.permissions.catalog import (
normalize_email,
permission_catalog as access_permission_catalog,
scopes_grant,
)
from govoplan_core.security.time import utc_now
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -488,6 +494,35 @@ def _organization_directory_or_error() -> OrganizationDirectory:
return capability
def _optional_organization_directory() -> OrganizationDirectory | None:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
return None
capability = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
if not isinstance(capability, OrganizationDirectory):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
return capability
def _optional_idm_directory() -> IdmDirectory | None:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDM_DIRECTORY):
return None
capability = registry.require_capability(CAPABILITY_IDM_DIRECTORY)
if not isinstance(capability, IdmDirectory):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}")
return capability
def _idm_assignments_for_user(
idm_directory: IdmDirectory | None,
user: User,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
if idm_directory is None:
return ()
return tuple(idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id))
def _validate_external_function_source(*, tenant_id: str, source_module: str, function_id: str) -> None:
if source_module != ORGANIZATIONS_MODULE_ID:
raise HTTPException(
@@ -660,7 +695,7 @@ def permission_catalog(
category=item.category,
level=item.level,
)
for item in ALL_PERMISSIONS
for item in access_permission_catalog(include_legacy=True)
if item.level == "tenant" or has_scope(principal, "system:roles:read") or has_scope(principal, "system:access:read") or has_scope(principal, "system:governance:read")
]
return PermissionCatalogResponse(permissions=items)
@@ -1850,8 +1885,9 @@ def revoke_function_delegation(
def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse:
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory()
return UserListDeltaResponse(
users=[_user_item(session, user, owner_ids=owner_ids) for user in users],
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users],
deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)),
has_more=False,
@@ -1872,13 +1908,14 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
)
}
owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory()
deleted = [
_delta_deleted_item(entry)
for entry in entries
if entry.resource_type == "access_user" and entry.resource_id not in visible
]
return UserListDeltaResponse(
users=[_user_item(session, user, owner_ids=owner_ids) for user in visible.values()],
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in visible.values()],
deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more,
@@ -1886,6 +1923,17 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
)
def _user_item_for_response(
session: Session,
user: User,
*,
owner_ids: set[str] | None = None,
idm_directory: IdmDirectory | None = None,
) -> UserAdminItem:
idm_assignments = _idm_assignments_for_user(idm_directory, user)
return _user_item(session, user, owner_ids=owner_ids, idm_assignments=idm_assignments)
@router.get("/users/delta", response_model=UserListDeltaResponse)
def list_users_delta(
tenant_id: str | None = Query(default=None),
@@ -1909,7 +1957,35 @@ def list_users(
tenant = _resolve_tenant(session, principal, tenant_id)
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
owner_ids = tenant_owner_user_ids(session, tenant.id)
return UserListResponse(users=[_user_item(session, user, owner_ids=owner_ids) for user in users])
idm_directory = _optional_idm_directory()
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users])
@router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
def get_user_access_explanation(
user_id: str,
tenant_id: str | None = Query(default=None),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:function:read", "access:role:read")),
):
tenant = _resolve_tenant(session, principal, tenant_id)
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
idm_directory = _optional_idm_directory()
explanation = build_user_access_explanation(
session,
user,
idm_directory=idm_directory,
organization_directory=_optional_organization_directory(),
include_system=False,
)
return UserAccessExplanationResponse(
user=_user_item_for_response(session, user, idm_directory=idm_directory),
role_sources=[source.to_dict() for source in explanation.role_sources],
scopes=[scope.to_dict() for scope in explanation.scopes],
function_facts=[fact.to_dict() for fact in explanation.function_facts],
)
@router.post("/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED)
@@ -1993,8 +2069,9 @@ def create_user(
},
)
session.commit()
idm_directory = _optional_idm_directory()
return UserCreateResponse(
user=_user_item(session, result.user),
user=_user_item_for_response(session, result.user, idm_directory=idm_directory),
account_created=result.account_created,
temporary_password=result.temporary_password,
)
@@ -2075,7 +2152,7 @@ def update_user(
details=payload.model_dump(exclude_none=True),
)
session.commit()
return _user_item(session, user)
return _user_item_for_response(session, user, idm_directory=_optional_idm_directory())
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse:
@@ -3152,7 +3229,7 @@ def create_tenant_api_key(
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
user_scopes = collect_user_scopes(session, user, include_system=False)
user_scopes = _user_item_for_response(session, user, idm_directory=_optional_idm_directory()).effective_scopes
requested = payload.scopes or ["campaign:read"]
invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)]
if invalid: