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

@@ -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: