diff --git a/src/govoplan_access/backend/api/v1/admin_common.py b/src/govoplan_access/backend/api/v1/admin_common.py index 28cd9b8..d6ea6c8 100644 --- a/src/govoplan_access/backend/api/v1/admin_common.py +++ b/src/govoplan_access/backend/api/v1/admin_common.py @@ -43,6 +43,7 @@ from govoplan_access.backend.db.models import ( UserGroupMembership, ) 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 @@ -143,13 +144,22 @@ def _user_item( *, owner_ids: set[str] | None = None, idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), + organization_directory: OrganizationDirectory | None = None, ) -> 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 [] + 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)) for role in external_roles: effective_scopes.update(role.permissions or []) diff --git a/src/govoplan_access/backend/api/v1/auth.py b/src/govoplan_access/backend/api/v1/auth.py index 1b2d2a6..d5432e8 100644 --- a/src/govoplan_access/backend/api/v1/auth.py +++ b/src/govoplan_access/backend/api/v1/auth.py @@ -18,6 +18,8 @@ from govoplan_core.api.v1.schemas import ( UserUiPreferences, ) 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_core.admin.settings import get_system_settings 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]: account = ( session.query(Account) @@ -215,6 +227,8 @@ def _me_response( service_account_id: str | None = None, include_system: bool = True, include_all_memberships: bool = True, + identity_directory: IdentityDirectory | None = None, + identity_id: str | None = None, ) -> MeResponse: tenant_roles = collect_user_roles(session, user) system_roles = collect_system_roles(session, account) if include_system else [] @@ -248,7 +262,7 @@ def _me_response( account_id=account.id, membership_id=user.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), group_ids=frozenset(group.id for group in groups), 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) def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): 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) if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE): raise HTTPException( @@ -300,6 +315,7 @@ def login(payload: LoginRequest, request: Request, response: Response, session: effective_scopes=me_payload.scopes, auth_method="session", session_id=created.model.id, + identity_directory=identity_directory, ).model_dump(), ) @@ -321,6 +337,7 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session = service_account_id=principal.principal.service_account_id, include_system=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, include_system=True, include_all_memberships=True, + identity_id=principal.principal.identity_id, ) @@ -430,6 +448,7 @@ def switch_tenant( tenant=tenant, auth_method="session", session_id=principal.session_id, + identity_id=principal.principal.identity_id, ) diff --git a/src/govoplan_access/backend/api/v1/routes.py b/src/govoplan_access/backend/api/v1/routes.py index d0ac600..a45aefc 100644 --- a/src/govoplan_access/backend/api/v1/routes.py +++ b/src/govoplan_access/backend/api/v1/routes.py @@ -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: diff --git a/src/govoplan_access/backend/auth/dependencies.py b/src/govoplan_access/backend/auth/dependencies.py index b8b3533..221b126 100644 --- a/src/govoplan_access/backend/auth/dependencies.py +++ b/src/govoplan_access/backend/auth/dependencies.py @@ -13,7 +13,9 @@ from govoplan_core.core.access import ( PrincipalRef, PrincipalResolver, ) +from govoplan_core.core.identity import IdentityDirectory 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.registry import PlatformRegistry 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, auth_session: AuthSession | None = None, idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (), + identity_directory: IdentityDirectory | None = None, extra_roles: tuple[Role, ...] = (), ) -> PrincipalRef: function_assignment_ids = list(collect_function_assignment_ids(session, user)) @@ -74,7 +77,7 @@ def _build_principal_ref( account_id=account.id, membership_id=user.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), group_ids=_principal_group_ids(session, user), role_ids=frozenset(sorted(dict.fromkeys(role_ids))), @@ -128,6 +131,8 @@ def _resolve_legacy_principal_ref( authorization: str | None, x_api_key: str | None, idm_directory: IdmDirectory | None = None, + identity_directory: IdentityDirectory | None = None, + organization_directory: OrganizationDirectory | None = None, ) -> PrincipalRef: token, source = _extract_token(request, authorization, x_api_key) 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") 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) effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or []) session.commit() @@ -160,6 +165,7 @@ def _resolve_legacy_principal_ref( scopes=effective_scopes, auth_method="api_key", idm_assignments=idm_assignments, + identity_directory=identity_directory, 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): 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_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) session.commit() return _build_principal_ref( @@ -193,6 +199,7 @@ def _resolve_legacy_principal_ref( scopes=scopes, auth_method="session", idm_assignments=idm_assignments, + identity_directory=identity_directory, extra_roles=idm_roles, ) @@ -257,8 +264,15 @@ def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator 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._identity_directory = identity_directory + self._organization_directory = organization_directory def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef: if not isinstance(request, Request): @@ -272,6 +286,8 @@ class LegacyPrincipalResolver: authorization=authorization, x_api_key=x_api_key, idm_directory=self._idm_directory, + identity_directory=self._identity_directory, + organization_directory=self._organization_directory, ) with get_database().session() as managed_session: return _resolve_legacy_principal_ref( @@ -280,6 +296,8 @@ class LegacyPrincipalResolver: authorization=authorization, x_api_key=x_api_key, idm_directory=self._idm_directory, + identity_directory=self._identity_directory, + organization_directory=self._organization_directory, ) diff --git a/src/govoplan_access/backend/directory.py b/src/govoplan_access/backend/directory.py index 1ba7645..6369071 100644 --- a/src/govoplan_access/backend/directory.py +++ b/src/govoplan_access/backend/directory.py @@ -13,7 +13,14 @@ from govoplan_core.core.access import ( OrganizationUnitRef, 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.organizations import ( + ORGANIZATIONS_MODULE_ID, + OrganizationDirectory, + OrganizationFunctionRef as DirectoryFunctionRef, + OrganizationUnitRef as DirectoryOrganizationUnitRef, +) from govoplan_access.backend.db.models import ( Account, 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: return OrganizationUnitRef( 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: return FunctionRef( 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: return FunctionAssignmentRef( id=item.id, @@ -134,8 +175,15 @@ def _idm_function_assignment_ref(item: IdmFunctionAssignmentRef, *, account_id: 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._identity_directory = identity_directory + self._organization_directory = organization_directory def get_account(self, account_id: str) -> AccountRef | None: with get_database().session() as session: @@ -230,6 +278,10 @@ class SqlAccessDirectory(AccessSemanticDirectory): return subject.label or subject.id 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: identity = session.get(Identity, identity_id) if identity is None: @@ -243,6 +295,16 @@ class SqlAccessDirectory(AccessSemanticDirectory): return _identity_ref(identity, links) 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: accounts = ( session.query(Account) @@ -254,11 +316,19 @@ class SqlAccessDirectory(AccessSemanticDirectory): return tuple(_account_ref(account) for account in accounts) 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: item = session.get(OrganizationUnit, organization_unit_id) return _organization_unit_ref(item) if item is not None else None 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: items = ( session.query(OrganizationUnit) @@ -269,6 +339,10 @@ class SqlAccessDirectory(AccessSemanticDirectory): return tuple(_organization_unit_ref(item) for item in items) 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: function = session.get(Function, function_id) if function is None: @@ -288,6 +362,22 @@ class SqlAccessDirectory(AccessSemanticDirectory): *, include_subunits: bool = False, ) -> 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: unit_ids = {organization_unit_id} if include_subunits: @@ -345,3 +435,33 @@ class SqlAccessDirectory(AccessSemanticDirectory): ) deduped = {item.id: item for item in assignments} 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()} diff --git a/src/govoplan_access/backend/explanation.py b/src/govoplan_access/backend/explanation.py index f78f5f5..c080e0b 100644 --- a/src/govoplan_access/backend/explanation.py +++ b/src/govoplan_access/backend/explanation.py @@ -27,8 +27,9 @@ from govoplan_access.backend.semantic import ( identity_id_for_account, ) 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.organizations import OrganizationDirectory +from govoplan_core.core.organizations import ORGANIZATIONS_MODULE_ID, OrganizationDirectory from govoplan_core.db.session import get_database from govoplan_access.backend.permissions.catalog import expand_scopes, scopes_grant @@ -159,10 +160,12 @@ class SqlAccessExplanationService(AccessExplanationService): def __init__( self, *, + identity_directory: IdentityDirectory | None = None, idm_directory: IdmDirectory | None = None, organization_directory: OrganizationDirectory | None = None, resource_explanation_providers: Iterable[ResourceAccessExplanationProvider] = (), ) -> None: + self._identity_directory = identity_directory self._idm_directory = idm_directory self._organization_directory = organization_directory self._resource_explanation_providers = tuple(resource_explanation_providers) @@ -178,6 +181,7 @@ class SqlAccessExplanationService(AccessExplanationService): session, principal, required_scope, + identity_directory=self._identity_directory, idm_directory=self._idm_directory, organization_directory=self._organization_directory, ) @@ -196,6 +200,7 @@ class SqlAccessExplanationService(AccessExplanationService): session, principal, action, + identity_directory=self._identity_directory, idm_directory=self._idm_directory, organization_directory=self._organization_directory, ) @@ -250,12 +255,17 @@ def _scope_provenance( principal: PrincipalRef, required_scope: str, *, + identity_directory: IdentityDirectory | None = None, idm_directory: IdmDirectory | None = None, organization_directory: OrganizationDirectory | None = None, ) -> list[AccessDecisionProvenance]: items: list[AccessDecisionProvenance] = [] 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: items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link")) 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) 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: return [], [] 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) .filter( ExternalFunctionRoleAssignment.tenant_id == user.tenant_id, + ExternalFunctionRoleAssignment.source_module == ORGANIZATIONS_MODULE_ID, ExternalFunctionRoleAssignment.function_id.in_(function_ids), Role.tenant_id == user.tenant_id, ) @@ -446,7 +463,7 @@ def _idm_function_role_sources( mappings = mappings_by_function.get(assignment.function_id, []) function_facts.append( FunctionFactExplanation( - source_module="organizations", + source_module=ORGANIZATIONS_MODULE_ID, assignment_id=assignment.id, tenant_id=assignment.tenant_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) +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]: roles = ( session.query(Role) diff --git a/src/govoplan_access/backend/manifest.py b/src/govoplan_access/backend/manifest.py index 32c55ad..5118760 100644 --- a/src/govoplan_access/backend/manifest.py +++ b/src/govoplan_access/backend/manifest.py @@ -21,6 +21,7 @@ from govoplan_core.core.access import ( ) from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_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.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory 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: from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver - idm_directory = _optional_idm_directory(context) - return LegacyPrincipalResolver(idm_directory=idm_directory) + return LegacyPrincipalResolver( + 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: @@ -483,13 +487,30 @@ def _tenant_context_switcher(context: ModuleContext) -> object: def _access_directory(context: ModuleContext) -> object: 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: 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: @@ -525,6 +546,7 @@ def _access_explanation_service(context: ModuleContext) -> object: from govoplan_access.backend.explanation import SqlAccessExplanationService return SqlAccessExplanationService( + identity_directory=_optional_identity_directory(context), idm_directory=_optional_idm_directory(context), organization_directory=_optional_organization_directory(context), resource_explanation_providers=_resource_explanation_providers(context), diff --git a/src/govoplan_access/backend/semantic.py b/src/govoplan_access/backend/semantic.py index c7b1279..261b979 100644 --- a/src/govoplan_access/backend/semantic.py +++ b/src/govoplan_access/backend/semantic.py @@ -17,7 +17,9 @@ from govoplan_access.backend.db.models import ( Role, User, ) +from govoplan_core.core.identity import IdentityDirectory 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 @@ -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) return identity.id if identity is not None else None @@ -163,13 +174,20 @@ def collect_external_function_roles( user: User, assignments: Iterable[OrganizationFunctionAssignmentRef], *, - source_module: str = "organizations", + source_module: str = ORGANIZATIONS_MODULE_ID, + organization_directory: OrganizationDirectory | None = None, ) -> list[Role]: function_ids = sorted({ assignment.function_id for assignment in assignments 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: return [] return ( @@ -184,3 +202,13 @@ def collect_external_function_roles( .order_by(Role.name.asc()) .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"