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

@@ -43,6 +43,7 @@ from govoplan_access.backend.db.models import (
UserGroupMembership, UserGroupMembership,
) )
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
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
@@ -143,13 +144,22 @@ def _user_item(
*, *,
owner_ids: set[str] | None = None, owner_ids: set[str] | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
organization_directory: OrganizationDirectory | None = None,
) -> UserAdminItem: ) -> UserAdminItem:
account = session.get(Account, user.account_id) account = session.get(Account, user.account_id)
if account is None: if account is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
groups = collect_user_groups(session, user) groups = collect_user_groups(session, user)
roles = collect_direct_user_roles(session, user) roles = collect_direct_user_roles(session, user)
external_roles = collect_external_function_roles(session, user, idm_assignments) if idm_assignments else [] external_roles = (
collect_external_function_roles(
session,
user,
idm_assignments,
organization_directory=organization_directory,
)
if idm_assignments else []
)
effective_scopes = set(collect_user_scopes(session, user, include_system=False)) effective_scopes = set(collect_user_scopes(session, user, include_system=False))
for role in external_roles: for role in external_roles:
effective_scopes.update(role.permissions or []) effective_scopes.update(role.permissions or [])

View File

@@ -18,6 +18,8 @@ from govoplan_core.api.v1.schemas import (
UserUiPreferences, UserUiPreferences,
) )
from govoplan_core.core.access import AuthMethod, PrincipalRef from govoplan_core.core.access import AuthMethod, PrincipalRef
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.registry import PlatformRegistry
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
from govoplan_core.admin.settings import get_system_settings from govoplan_core.admin.settings import get_system_settings
from govoplan_core.audit.logging import audit_from_principal from govoplan_core.audit.logging import audit_from_principal
@@ -176,6 +178,16 @@ def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[s
} }
def _identity_directory_from_request(request: Request) -> IdentityDirectory | None:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry) 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 _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]: def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
account = ( account = (
session.query(Account) session.query(Account)
@@ -215,6 +227,8 @@ def _me_response(
service_account_id: str | None = None, service_account_id: str | None = None,
include_system: bool = True, include_system: bool = True,
include_all_memberships: bool = True, include_all_memberships: bool = True,
identity_directory: IdentityDirectory | None = None,
identity_id: str | None = None,
) -> MeResponse: ) -> MeResponse:
tenant_roles = collect_user_roles(session, user) tenant_roles = collect_user_roles(session, user)
system_roles = collect_system_roles(session, account) if include_system else [] system_roles = collect_system_roles(session, account) if include_system else []
@@ -248,7 +262,7 @@ def _me_response(
account_id=account.id, account_id=account.id,
membership_id=user.id, membership_id=user.id,
tenant_id=tenant.id, tenant_id=tenant.id,
identity_id=identity_id_for_account(session, account.id), identity_id=identity_id or identity_id_for_account(session, account.id, identity_directory=identity_directory),
scopes=frozenset(scopes), scopes=frozenset(scopes),
group_ids=frozenset(group.id for group in groups), group_ids=frozenset(group.id for group in groups),
role_ids=frozenset(role.id for role in tenant_roles + system_roles), role_ids=frozenset(role.id for role in tenant_roles + system_roles),
@@ -271,7 +285,8 @@ def _me_response(
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
account, user, tenant = _resolve_login_user(session, payload) account, user, tenant = _resolve_login_user(session, payload)
me_payload = _me_response(session, account=account, user=user, tenant=tenant) identity_directory = _identity_directory_from_request(request)
me_payload = _me_response(session, account=account, user=user, tenant=tenant, identity_directory=identity_directory)
maintenance_mode = saved_maintenance_mode(session) maintenance_mode = saved_maintenance_mode(session)
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE): if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE):
raise HTTPException( raise HTTPException(
@@ -300,6 +315,7 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
effective_scopes=me_payload.scopes, effective_scopes=me_payload.scopes,
auth_method="session", auth_method="session",
session_id=created.model.id, session_id=created.model.id,
identity_directory=identity_directory,
).model_dump(), ).model_dump(),
) )
@@ -321,6 +337,7 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
service_account_id=principal.principal.service_account_id, service_account_id=principal.principal.service_account_id,
include_system=principal.auth_session is not None, include_system=principal.auth_session is not None,
include_all_memberships=principal.auth_session is not None, include_all_memberships=principal.auth_session is not None,
identity_id=principal.principal.identity_id,
) )
@@ -401,6 +418,7 @@ def update_profile(
session_id=principal.session_id, session_id=principal.session_id,
include_system=True, include_system=True,
include_all_memberships=True, include_all_memberships=True,
identity_id=principal.principal.identity_id,
) )
@@ -430,6 +448,7 @@ def switch_tenant(
tenant=tenant, tenant=tenant,
auth_method="session", auth_method="session",
session_id=principal.session_id, session_id=principal.session_id,
identity_id=principal.principal.identity_id,
) )

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.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.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.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.api.v1.schemas import DeltaDeletedItem
@@ -507,6 +508,16 @@ def _optional_organization_directory() -> OrganizationDirectory | None:
return capability 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: def _optional_idm_directory() -> IdmDirectory | None:
registry = get_registry() registry = get_registry()
if registry is None or not registry.has_capability(CAPABILITY_IDM_DIRECTORY): 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 from govoplan_access.backend.explanation import SqlAccessExplanationService
return SqlAccessExplanationService( return SqlAccessExplanationService(
identity_directory=_optional_identity_directory(),
idm_directory=_optional_idm_directory(), idm_directory=_optional_idm_directory(),
organization_directory=_optional_organization_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)) 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) account = session.get(Account, user.account_id)
if account is None: if account is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
idm_assignments = _idm_assignments_for_user(idm_directory, user) 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)) scopes = set(collect_user_scopes(session, user, include_system=True))
for role in idm_roles: for role in idm_roles:
scopes.update(role.permissions or []) 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, account_id=account.id,
membership_id=user.id, membership_id=user.id,
tenant_id=user.tenant_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)), scopes=frozenset(sorted(scopes)),
group_ids=frozenset(group.id for group in collect_user_groups(session, user)), group_ids=frozenset(group.id for group in collect_user_groups(session, user)),
role_ids=frozenset(sorted(dict.fromkeys(role_ids))), 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() 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) owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
return UserListDeltaResponse( 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=[], deleted=[],
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)), watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)),
has_more=False, 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) owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
deleted = [ deleted = [
_delta_deleted_item(entry) _delta_deleted_item(entry)
for entry in entries for entry in entries
if entry.resource_type == "access_user" and entry.resource_id not in visible if entry.resource_type == "access_user" and entry.resource_id not in visible
] ]
return UserListDeltaResponse( 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, deleted=deleted,
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more), watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more),
has_more=has_more, has_more=has_more,
@@ -1980,9 +2001,16 @@ def _user_item_for_response(
*, *,
owner_ids: set[str] | None = None, owner_ids: set[str] | None = None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> UserAdminItem: ) -> UserAdminItem:
idm_assignments = _idm_assignments_for_user(idm_directory, user) 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) @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() 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) owner_ids = tenant_owner_user_ids(session, tenant.id)
idm_directory = _optional_idm_directory() 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) @router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
@@ -2024,15 +2053,16 @@ def get_user_access_explanation(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
explanation = build_user_access_explanation( explanation = build_user_access_explanation(
session, session,
user, user,
idm_directory=idm_directory, idm_directory=idm_directory,
organization_directory=_optional_organization_directory(), organization_directory=organization_directory,
include_system=False, include_system=False,
) )
return UserAccessExplanationResponse( 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], role_sources=[source.to_dict() for source in explanation.role_sources],
scopes=[scope.to_dict() for scope in explanation.scopes], scopes=[scope.to_dict() for scope in explanation.scopes],
function_facts=[fact.to_dict() for fact in explanation.function_facts], function_facts=[fact.to_dict() for fact in explanation.function_facts],
@@ -2054,7 +2084,15 @@ def get_resource_access_explanation(
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
idm_directory = _optional_idm_directory() 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( provenance = _access_explanation_service_or_error().explain_resource_provenance(
target_principal, target_principal,
resource_type=resource_type, resource_type=resource_type,
@@ -2062,7 +2100,7 @@ def get_resource_access_explanation(
action=action, action=action,
) )
return ResourceAccessExplanationResponse( 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_type=resource_type,
resource_id=resource_id, resource_id=resource_id,
action=action, action=action,
@@ -2152,8 +2190,9 @@ def create_user(
) )
session.commit() session.commit()
idm_directory = _optional_idm_directory() idm_directory = _optional_idm_directory()
organization_directory = _optional_organization_directory()
return UserCreateResponse( 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, account_created=result.account_created,
temporary_password=result.temporary_password, temporary_password=result.temporary_password,
) )
@@ -2234,7 +2273,12 @@ def update_user(
details=payload.model_dump(exclude_none=True), details=payload.model_dump(exclude_none=True),
) )
session.commit() 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: 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() 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: if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found") 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"] requested = payload.scopes or ["campaign:read"]
invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)] invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)]
if invalid: if invalid:

View File

@@ -13,7 +13,9 @@ from govoplan_core.core.access import (
PrincipalRef, PrincipalRef,
PrincipalResolver, PrincipalResolver,
) )
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.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
@@ -63,6 +65,7 @@ def _build_principal_ref(
api_key: ApiKey | None = None, api_key: ApiKey | None = None,
auth_session: AuthSession | None = None, auth_session: AuthSession | None = None,
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
identity_directory: IdentityDirectory | None = None,
extra_roles: tuple[Role, ...] = (), extra_roles: tuple[Role, ...] = (),
) -> PrincipalRef: ) -> PrincipalRef:
function_assignment_ids = list(collect_function_assignment_ids(session, user)) function_assignment_ids = list(collect_function_assignment_ids(session, user))
@@ -74,7 +77,7 @@ def _build_principal_ref(
account_id=account.id, account_id=account.id,
membership_id=user.id, membership_id=user.id,
tenant_id=tenant_id, tenant_id=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(scopes), scopes=frozenset(scopes),
group_ids=_principal_group_ids(session, user), group_ids=_principal_group_ids(session, user),
role_ids=frozenset(sorted(dict.fromkeys(role_ids))), role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
@@ -128,6 +131,8 @@ def _resolve_legacy_principal_ref(
authorization: str | None, authorization: str | None,
x_api_key: str | None, x_api_key: str | None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
identity_directory: IdentityDirectory | None = None,
organization_directory: OrganizationDirectory | None = None,
) -> PrincipalRef: ) -> PrincipalRef:
token, source = _extract_token(request, authorization, x_api_key) token, source = _extract_token(request, authorization, x_api_key)
if not token: if not token:
@@ -147,7 +152,7 @@ def _resolve_legacy_principal_ref(
): ):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id) idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id)
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))
user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles) user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles)
effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or []) effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or [])
session.commit() session.commit()
@@ -160,6 +165,7 @@ def _resolve_legacy_principal_ref(
scopes=effective_scopes, scopes=effective_scopes,
auth_method="api_key", auth_method="api_key",
idm_assignments=idm_assignments, idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles, extra_roles=idm_roles,
) )
@@ -181,7 +187,7 @@ def _resolve_legacy_principal_ref(
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token): if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id) idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id)
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 = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles) scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles)
session.commit() session.commit()
return _build_principal_ref( return _build_principal_ref(
@@ -193,6 +199,7 @@ def _resolve_legacy_principal_ref(
scopes=scopes, scopes=scopes,
auth_method="session", auth_method="session",
idm_assignments=idm_assignments, idm_assignments=idm_assignments,
identity_directory=identity_directory,
extra_roles=idm_roles, extra_roles=idm_roles,
) )
@@ -257,8 +264,15 @@ def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator
class LegacyPrincipalResolver: class LegacyPrincipalResolver:
def __init__(self, idm_directory: IdmDirectory | None = None) -> None: 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._idm_directory = idm_directory
self._identity_directory = identity_directory
self._organization_directory = organization_directory
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef: def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
if not isinstance(request, Request): if not isinstance(request, Request):
@@ -272,6 +286,8 @@ class LegacyPrincipalResolver:
authorization=authorization, authorization=authorization,
x_api_key=x_api_key, x_api_key=x_api_key,
idm_directory=self._idm_directory, idm_directory=self._idm_directory,
identity_directory=self._identity_directory,
organization_directory=self._organization_directory,
) )
with get_database().session() as managed_session: with get_database().session() as managed_session:
return _resolve_legacy_principal_ref( return _resolve_legacy_principal_ref(
@@ -280,6 +296,8 @@ class LegacyPrincipalResolver:
authorization=authorization, authorization=authorization,
x_api_key=x_api_key, x_api_key=x_api_key,
idm_directory=self._idm_directory, idm_directory=self._idm_directory,
identity_directory=self._identity_directory,
organization_directory=self._organization_directory,
) )

View File

@@ -13,7 +13,14 @@ from govoplan_core.core.access import (
OrganizationUnitRef, OrganizationUnitRef,
UserRef, UserRef,
) )
from govoplan_core.core.identity import IdentityDirectory, IdentityRef as DirectoryIdentityRef
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef
from govoplan_core.core.organizations import (
ORGANIZATIONS_MODULE_ID,
OrganizationDirectory,
OrganizationFunctionRef as DirectoryFunctionRef,
OrganizationUnitRef as DirectoryOrganizationUnitRef,
)
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
Account, Account,
Function, Function,
@@ -73,6 +80,16 @@ def _identity_ref(identity: Identity, account_links: list[IdentityAccountLink])
) )
def _directory_identity_ref(identity: DirectoryIdentityRef) -> IdentityRef:
return IdentityRef(
id=identity.id,
display_name=identity.display_name,
primary_account_id=identity.primary_account_id,
account_ids=tuple(identity.account_ids),
status=identity.status, # type: ignore[arg-type]
)
def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef: def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
return OrganizationUnitRef( return OrganizationUnitRef(
id=item.id, id=item.id,
@@ -83,6 +100,16 @@ def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
) )
def _directory_organization_unit_ref(item: DirectoryOrganizationUnitRef) -> OrganizationUnitRef:
return OrganizationUnitRef(
id=item.id,
tenant_id=item.tenant_id,
name=item.name,
parent_id=item.parent_id,
status=item.status, # type: ignore[arg-type]
)
def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef: def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef:
return FunctionRef( return FunctionRef(
id=function.id, id=function.id,
@@ -97,6 +124,20 @@ def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef:
) )
def _directory_function_ref(function: DirectoryFunctionRef, role_ids: Iterable[str]) -> FunctionRef:
return FunctionRef(
id=function.id,
tenant_id=function.tenant_id,
organization_unit_id=function.organization_unit_id,
slug=function.slug,
name=function.name,
role_ids=tuple(role_ids),
delegable=function.delegable,
act_in_place_allowed=function.act_in_place_allowed,
status=function.status, # type: ignore[arg-type]
)
def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef: def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef:
return FunctionAssignmentRef( return FunctionAssignmentRef(
id=item.id, id=item.id,
@@ -134,8 +175,15 @@ def _idm_function_assignment_ref(item: IdmFunctionAssignmentRef, *, account_id:
class SqlAccessDirectory(AccessSemanticDirectory): class SqlAccessDirectory(AccessSemanticDirectory):
def __init__(self, idm_directory: IdmDirectory | None = None) -> None: 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._idm_directory = idm_directory
self._identity_directory = identity_directory
self._organization_directory = organization_directory
def get_account(self, account_id: str) -> AccountRef | None: def get_account(self, account_id: str) -> AccountRef | None:
with get_database().session() as session: with get_database().session() as session:
@@ -230,6 +278,10 @@ class SqlAccessDirectory(AccessSemanticDirectory):
return subject.label or subject.id return subject.label or subject.id
def get_identity(self, identity_id: str) -> IdentityRef | None: def get_identity(self, identity_id: str) -> IdentityRef | None:
if self._identity_directory is not None:
identity = self._identity_directory.get_identity(identity_id)
if identity is not None:
return _directory_identity_ref(identity)
with get_database().session() as session: with get_database().session() as session:
identity = session.get(Identity, identity_id) identity = session.get(Identity, identity_id)
if identity is None: if identity is None:
@@ -243,6 +295,16 @@ class SqlAccessDirectory(AccessSemanticDirectory):
return _identity_ref(identity, links) return _identity_ref(identity, links)
def accounts_for_identity(self, identity_id: str) -> tuple[AccountRef, ...]: def accounts_for_identity(self, identity_id: str) -> tuple[AccountRef, ...]:
if self._identity_directory is not None:
links = tuple(self._identity_directory.accounts_for_identity(identity_id))
if links:
account_ids = [link.account_id for link in links]
with get_database().session() as session:
accounts = {
account.id: account
for account in session.query(Account).filter(Account.id.in_(account_ids)).all()
}
return tuple(_account_ref(accounts[account_id]) for account_id in account_ids if account_id in accounts)
with get_database().session() as session: with get_database().session() as session:
accounts = ( accounts = (
session.query(Account) session.query(Account)
@@ -254,11 +316,19 @@ class SqlAccessDirectory(AccessSemanticDirectory):
return tuple(_account_ref(account) for account in accounts) return tuple(_account_ref(account) for account in accounts)
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None: def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
if self._organization_directory is not None:
item = self._organization_directory.get_organization_unit(organization_unit_id)
if item is not None:
return _directory_organization_unit_ref(item)
with get_database().session() as session: with get_database().session() as session:
item = session.get(OrganizationUnit, organization_unit_id) item = session.get(OrganizationUnit, organization_unit_id)
return _organization_unit_ref(item) if item is not None else None return _organization_unit_ref(item) if item is not None else None
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]: def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
if self._organization_directory is not None:
items = tuple(self._organization_directory.organization_units_for_tenant(tenant_id))
if items:
return tuple(_directory_organization_unit_ref(item) for item in items)
with get_database().session() as session: with get_database().session() as session:
items = ( items = (
session.query(OrganizationUnit) session.query(OrganizationUnit)
@@ -269,6 +339,10 @@ class SqlAccessDirectory(AccessSemanticDirectory):
return tuple(_organization_unit_ref(item) for item in items) return tuple(_organization_unit_ref(item) for item in items)
def get_function(self, function_id: str) -> FunctionRef | None: def get_function(self, function_id: str) -> FunctionRef | None:
if self._organization_directory is not None:
function = self._organization_directory.get_function(function_id)
if function is not None:
return _directory_function_ref(function, self._external_function_role_ids(function.id, tenant_id=function.tenant_id))
with get_database().session() as session: with get_database().session() as session:
function = session.get(Function, function_id) function = session.get(Function, function_id)
if function is None: if function is None:
@@ -288,6 +362,22 @@ class SqlAccessDirectory(AccessSemanticDirectory):
*, *,
include_subunits: bool = False, include_subunits: bool = False,
) -> tuple[FunctionRef, ...]: ) -> tuple[FunctionRef, ...]:
if self._organization_directory is not None:
functions = tuple(
self._organization_directory.functions_for_organization_unit(
organization_unit_id,
include_subunits=include_subunits,
)
)
if functions:
role_ids_by_function = self._external_function_role_ids_by_function(
[item.id for item in functions],
tenant_id=functions[0].tenant_id,
)
return tuple(
_directory_function_ref(item, role_ids_by_function.get(item.id, ()))
for item in functions
)
with get_database().session() as session: with get_database().session() as session:
unit_ids = {organization_unit_id} unit_ids = {organization_unit_id}
if include_subunits: if include_subunits:
@@ -345,3 +435,33 @@ class SqlAccessDirectory(AccessSemanticDirectory):
) )
deduped = {item.id: item for item in assignments} deduped = {item.id: item for item in assignments}
return tuple(deduped.values()) return tuple(deduped.values())
def _external_function_role_ids(self, function_id: str, *, tenant_id: str) -> tuple[str, ...]:
return self._external_function_role_ids_by_function([function_id], tenant_id=tenant_id).get(function_id, ())
def _external_function_role_ids_by_function(
self,
function_ids: Iterable[str],
*,
tenant_id: str,
) -> dict[str, tuple[str, ...]]:
ids = sorted({str(function_id) for function_id in function_ids if function_id})
if not ids:
return {}
from govoplan_access.backend.db.models import ExternalFunctionRoleAssignment
with get_database().session() as session:
rows = (
session.query(ExternalFunctionRoleAssignment.function_id, ExternalFunctionRoleAssignment.role_id)
.filter(
ExternalFunctionRoleAssignment.tenant_id == tenant_id,
ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID,
ExternalFunctionRoleAssignment.function_id.in_(ids),
)
.order_by(ExternalFunctionRoleAssignment.created_at.asc())
.all()
)
result: dict[str, list[str]] = {}
for function_id, role_id in rows:
result.setdefault(function_id, []).append(role_id)
return {function_id: tuple(role_ids) for function_id, role_ids in result.items()}

View File

@@ -27,8 +27,9 @@ from govoplan_access.backend.semantic import (
identity_id_for_account, identity_id_for_account,
) )
from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef, ResourceAccessExplanationProvider from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef, ResourceAccessExplanationProvider
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 ORGANIZATIONS_MODULE_ID, OrganizationDirectory
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_access.backend.permissions.catalog import expand_scopes, scopes_grant from govoplan_access.backend.permissions.catalog import expand_scopes, scopes_grant
@@ -159,10 +160,12 @@ class SqlAccessExplanationService(AccessExplanationService):
def __init__( def __init__(
self, self,
*, *,
identity_directory: IdentityDirectory | None = None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
resource_explanation_providers: Iterable[ResourceAccessExplanationProvider] = (), resource_explanation_providers: Iterable[ResourceAccessExplanationProvider] = (),
) -> None: ) -> None:
self._identity_directory = identity_directory
self._idm_directory = idm_directory self._idm_directory = idm_directory
self._organization_directory = organization_directory self._organization_directory = organization_directory
self._resource_explanation_providers = tuple(resource_explanation_providers) self._resource_explanation_providers = tuple(resource_explanation_providers)
@@ -178,6 +181,7 @@ class SqlAccessExplanationService(AccessExplanationService):
session, session,
principal, principal,
required_scope, required_scope,
identity_directory=self._identity_directory,
idm_directory=self._idm_directory, idm_directory=self._idm_directory,
organization_directory=self._organization_directory, organization_directory=self._organization_directory,
) )
@@ -196,6 +200,7 @@ class SqlAccessExplanationService(AccessExplanationService):
session, session,
principal, principal,
action, action,
identity_directory=self._identity_directory,
idm_directory=self._idm_directory, idm_directory=self._idm_directory,
organization_directory=self._organization_directory, organization_directory=self._organization_directory,
) )
@@ -250,12 +255,17 @@ def _scope_provenance(
principal: PrincipalRef, principal: PrincipalRef,
required_scope: str, required_scope: str,
*, *,
identity_directory: IdentityDirectory | None = None,
idm_directory: IdmDirectory | None = None, idm_directory: IdmDirectory | None = None,
organization_directory: OrganizationDirectory | None = None, organization_directory: OrganizationDirectory | None = None,
) -> list[AccessDecisionProvenance]: ) -> list[AccessDecisionProvenance]:
items: list[AccessDecisionProvenance] = [] items: list[AccessDecisionProvenance] = []
account = session.get(Account, principal.account_id) account = session.get(Account, principal.account_id)
identity_id = principal.identity_id or identity_id_for_account(session, principal.account_id) identity_id = principal.identity_id or identity_id_for_account(
session,
principal.account_id,
identity_directory=identity_directory,
)
if identity_id: if identity_id:
items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link")) items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link"))
items.append( items.append(
@@ -421,6 +431,12 @@ def _idm_function_role_sources(
for assignment in idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id) for assignment in idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id)
if assignment.tenant_id == user.tenant_id and assignment.status == "active" if assignment.tenant_id == user.tenant_id and assignment.status == "active"
] ]
if organization_directory is not None:
assignments = [
assignment
for assignment in assignments
if _organization_function_active(organization_directory, assignment)
]
if not assignments: if not assignments:
return [], [] return [], []
function_ids = sorted({assignment.function_id for assignment in assignments}) function_ids = sorted({assignment.function_id for assignment in assignments})
@@ -429,6 +445,7 @@ def _idm_function_role_sources(
.join(Role, ExternalFunctionRoleAssignment.role_id == Role.id) .join(Role, ExternalFunctionRoleAssignment.role_id == Role.id)
.filter( .filter(
ExternalFunctionRoleAssignment.tenant_id == user.tenant_id, ExternalFunctionRoleAssignment.tenant_id == user.tenant_id,
ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID,
ExternalFunctionRoleAssignment.function_id.in_(function_ids), ExternalFunctionRoleAssignment.function_id.in_(function_ids),
Role.tenant_id == user.tenant_id, Role.tenant_id == user.tenant_id,
) )
@@ -446,7 +463,7 @@ def _idm_function_role_sources(
mappings = mappings_by_function.get(assignment.function_id, []) mappings = mappings_by_function.get(assignment.function_id, [])
function_facts.append( function_facts.append(
FunctionFactExplanation( FunctionFactExplanation(
source_module="organizations", source_module=ORGANIZATIONS_MODULE_ID,
assignment_id=assignment.id, assignment_id=assignment.id,
tenant_id=assignment.tenant_id, tenant_id=assignment.tenant_id,
identity_id=assignment.identity_id, identity_id=assignment.identity_id,
@@ -501,6 +518,14 @@ def _organization_labels(
return (function.name if function is not None else None, unit.name if unit is not None else None) return (function.name if function is not None else None, unit.name if unit is not None else None)
def _organization_function_active(
organization_directory: OrganizationDirectory,
assignment: OrganizationFunctionAssignmentRef,
) -> bool:
function = organization_directory.get_function(assignment.function_id)
return function is not None and function.tenant_id == assignment.tenant_id and function.status == "active"
def _system_role_sources(session: Session, account: Account) -> list[AccessRoleSourceExplanation]: def _system_role_sources(session: Session, account: Account) -> list[AccessRoleSourceExplanation]:
roles = ( roles = (
session.query(Role) session.query(Role)

View File

@@ -21,6 +21,7 @@ from govoplan_core.core.access import (
) )
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS from govoplan_core.core.files import CAPABILITY_FILES_ACCESS
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory
from govoplan_core.core.module_guards import persistent_table_uninstall_guard from govoplan_core.core.module_guards import persistent_table_uninstall_guard
@@ -455,8 +456,11 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
def _legacy_principal_resolver(context: ModuleContext) -> object: def _legacy_principal_resolver(context: ModuleContext) -> object:
from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver
idm_directory = _optional_idm_directory(context) return LegacyPrincipalResolver(
return LegacyPrincipalResolver(idm_directory=idm_directory) idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _legacy_permission_evaluator(context: ModuleContext) -> object: def _legacy_permission_evaluator(context: ModuleContext) -> object:
@@ -483,13 +487,30 @@ def _tenant_context_switcher(context: ModuleContext) -> object:
def _access_directory(context: ModuleContext) -> object: def _access_directory(context: ModuleContext) -> object:
from govoplan_access.backend.directory import SqlAccessDirectory from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory(idm_directory=_optional_idm_directory(context)) return SqlAccessDirectory(
idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _access_semantic_directory(context: ModuleContext) -> object: def _access_semantic_directory(context: ModuleContext) -> object:
from govoplan_access.backend.directory import SqlAccessDirectory from govoplan_access.backend.directory import SqlAccessDirectory
return SqlAccessDirectory(idm_directory=_optional_idm_directory(context)) return SqlAccessDirectory(
idm_directory=_optional_idm_directory(context),
identity_directory=_optional_identity_directory(context),
organization_directory=_optional_organization_directory(context),
)
def _optional_identity_directory(context: ModuleContext) -> IdentityDirectory | None:
if not context.registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
return None
capability = context.registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
if not isinstance(capability, IdentityDirectory):
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
return capability
def _optional_idm_directory(context: ModuleContext) -> IdmDirectory | None: def _optional_idm_directory(context: ModuleContext) -> IdmDirectory | None:
@@ -525,6 +546,7 @@ def _access_explanation_service(context: ModuleContext) -> object:
from govoplan_access.backend.explanation import SqlAccessExplanationService from govoplan_access.backend.explanation import SqlAccessExplanationService
return SqlAccessExplanationService( return SqlAccessExplanationService(
identity_directory=_optional_identity_directory(context),
idm_directory=_optional_idm_directory(context), idm_directory=_optional_idm_directory(context),
organization_directory=_optional_organization_directory(context), organization_directory=_optional_organization_directory(context),
resource_explanation_providers=_resource_explanation_providers(context), resource_explanation_providers=_resource_explanation_providers(context),

View File

@@ -17,7 +17,9 @@ from govoplan_access.backend.db.models import (
Role, Role,
User, User,
) )
from govoplan_core.core.identity import IdentityDirectory
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, OrganizationDirectory
from govoplan_core.security.time import utc_now from govoplan_core.security.time import utc_now
@@ -35,7 +37,16 @@ def primary_identity_for_account(session: Session, account_id: str) -> Identity
) )
def identity_id_for_account(session: Session, account_id: str) -> str | None: def identity_id_for_account(
session: Session,
account_id: str,
*,
identity_directory: IdentityDirectory | None = None,
) -> str | None:
if identity_directory is not None:
identity = identity_directory.identity_for_account(account_id)
if identity is not None and identity.status == "active":
return identity.id
identity = primary_identity_for_account(session, account_id) identity = primary_identity_for_account(session, account_id)
return identity.id if identity is not None else None return identity.id if identity is not None else None
@@ -163,13 +174,20 @@ def collect_external_function_roles(
user: User, user: User,
assignments: Iterable[OrganizationFunctionAssignmentRef], assignments: Iterable[OrganizationFunctionAssignmentRef],
*, *,
source_module: str = "organizations", source_module: str = ORGANIZATIONS_MODULE_ID,
organization_directory: OrganizationDirectory | None = None,
) -> list[Role]: ) -> list[Role]:
function_ids = sorted({ function_ids = sorted({
assignment.function_id assignment.function_id
for assignment in assignments for assignment in assignments
if assignment.tenant_id == user.tenant_id and assignment.status == "active" if assignment.tenant_id == user.tenant_id and assignment.status == "active"
}) })
if organization_directory is not None and source_module == ORGANIZATIONS_MODULE_ID:
function_ids = [
function_id
for function_id in function_ids
if _organization_function_active(organization_directory, function_id, tenant_id=user.tenant_id)
]
if not function_ids: if not function_ids:
return [] return []
return ( return (
@@ -184,3 +202,13 @@ def collect_external_function_roles(
.order_by(Role.name.asc()) .order_by(Role.name.asc())
.all() .all()
) )
def _organization_function_active(
organization_directory: OrganizationDirectory,
function_id: str,
*,
tenant_id: str,
) -> bool:
function = organization_directory.get_function(function_id)
return function is not None and function.tenant_id == tenant_id and function.status == "active"