Prefer canonical identity and organization directories

This commit is contained in:
2026-07-11 01:13:53 +02:00
parent 5b8baa6cde
commit 002d12e417
8 changed files with 322 additions and 31 deletions

View File

@@ -156,6 +156,7 @@ from govoplan_core.core.configuration_control import (
)
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
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
@@ -507,6 +508,16 @@ def _optional_organization_directory() -> OrganizationDirectory | None:
return capability
def _optional_identity_directory() -> IdentityDirectory | None:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
return None
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
return capability
def _optional_idm_directory() -> IdmDirectory | None:
registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDM_DIRECTORY):
@@ -527,6 +538,7 @@ def _access_explanation_service_or_error() -> AccessExplanationService:
from govoplan_access.backend.explanation import SqlAccessExplanationService
return SqlAccessExplanationService(
identity_directory=_optional_identity_directory(),
idm_directory=_optional_idm_directory(),
organization_directory=_optional_organization_directory(),
)
@@ -541,12 +553,19 @@ def _idm_assignments_for_user(
return tuple(idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id))
def _principal_ref_for_user(session: Session, user: User, *, idm_directory: IdmDirectory | None = None) -> PrincipalRef:
def _principal_ref_for_user(
session: Session,
user: User,
*,
idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> PrincipalRef:
account = session.get(Account, user.account_id)
if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
idm_assignments = _idm_assignments_for_user(idm_directory, user)
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments))
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
scopes = set(collect_user_scopes(session, user, include_system=True))
for role in idm_roles:
scopes.update(role.permissions or [])
@@ -558,7 +577,7 @@ def _principal_ref_for_user(session: Session, user: User, *, idm_directory: IdmD
account_id=account.id,
membership_id=user.id,
tenant_id=user.tenant_id,
identity_id=identity_id_for_account(session, account.id),
identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
scopes=frozenset(sorted(scopes)),
group_ids=frozenset(group.id for group in collect_user_groups(session, user)),
role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
@@ -1937,8 +1956,9 @@ def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDelt
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()
organization_directory = _optional_organization_directory()
return UserListDeltaResponse(
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users],
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users],
deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)),
has_more=False,
@@ -1960,13 +1980,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()
organization_directory = _optional_organization_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_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in visible.values()],
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_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,
@@ -1980,9 +2001,16 @@ def _user_item_for_response(
*,
owner_ids: set[str] | None = None,
idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | 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)
return _user_item(
session,
user,
owner_ids=owner_ids,
idm_assignments=idm_assignments,
organization_directory=organization_directory,
)
@router.get("/users/delta", response_model=UserListDeltaResponse)
@@ -2009,7 +2037,8 @@ def list_users(
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 UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users])
organization_directory = _optional_organization_directory()
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users])
@router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
@@ -2024,15 +2053,16 @@ def get_user_access_explanation(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
explanation = build_user_access_explanation(
session,
user,
idm_directory=idm_directory,
organization_directory=_optional_organization_directory(),
organization_directory=organization_directory,
include_system=False,
)
return UserAccessExplanationResponse(
user=_user_item_for_response(session, user, idm_directory=idm_directory),
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_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],
@@ -2054,7 +2084,15 @@ def get_resource_access_explanation(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
idm_directory = _optional_idm_directory()
target_principal = _principal_ref_for_user(session, user, idm_directory=idm_directory)
identity_directory = _optional_identity_directory()
organization_directory = _optional_organization_directory()
target_principal = _principal_ref_for_user(
session,
user,
idm_directory=idm_directory,
identity_directory=identity_directory,
organization_directory=organization_directory,
)
provenance = _access_explanation_service_or_error().explain_resource_provenance(
target_principal,
resource_type=resource_type,
@@ -2062,7 +2100,7 @@ def get_resource_access_explanation(
action=action,
)
return ResourceAccessExplanationResponse(
user=_user_item_for_response(session, user, idm_directory=idm_directory),
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
resource_type=resource_type,
resource_id=resource_id,
action=action,
@@ -2152,8 +2190,9 @@ def create_user(
)
session.commit()
idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
return UserCreateResponse(
user=_user_item_for_response(session, result.user, idm_directory=idm_directory),
user=_user_item_for_response(session, result.user, idm_directory=idm_directory, organization_directory=organization_directory),
account_created=result.account_created,
temporary_password=result.temporary_password,
)
@@ -2234,7 +2273,12 @@ def update_user(
details=payload.model_dump(exclude_none=True),
)
session.commit()
return _user_item_for_response(session, user, idm_directory=_optional_idm_directory())
return _user_item_for_response(
session,
user,
idm_directory=_optional_idm_directory(),
organization_directory=_optional_organization_directory(),
)
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse:
@@ -3311,7 +3355,12 @@ 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 = _user_item_for_response(session, user, idm_directory=_optional_idm_directory()).effective_scopes
user_scopes = _user_item_for_response(
session,
user,
idm_directory=_optional_idm_directory(),
organization_directory=_optional_organization_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: