From d7bf71a2d9ee4d99429b1bfb8148f88cb6c1d701 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 10 Jul 2026 18:57:19 +0200 Subject: [PATCH] Expose identity lookup API --- pyproject.toml | 2 +- src/govoplan_identity/backend/api/__init__.py | 1 + .../backend/api/v1/__init__.py | 1 + .../backend/api/v1/routes.py | 82 +++++++++++++++++++ .../backend/api/v1/schemas.py | 17 ++++ src/govoplan_identity/backend/manifest.py | 44 +++++++++- 6 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 src/govoplan_identity/backend/api/__init__.py create mode 100644 src/govoplan_identity/backend/api/v1/__init__.py create mode 100644 src/govoplan_identity/backend/api/v1/routes.py create mode 100644 src/govoplan_identity/backend/api/v1/schemas.py diff --git a/pyproject.toml b/pyproject.toml index 2393359..eb0964c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-identity" -version = "0.1.6" +version = "0.1.7" description = "GovOPlaN identity directory module." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_identity/backend/api/__init__.py b/src/govoplan_identity/backend/api/__init__.py new file mode 100644 index 0000000..7496936 --- /dev/null +++ b/src/govoplan_identity/backend/api/__init__.py @@ -0,0 +1 @@ +"""Identity API package.""" diff --git a/src/govoplan_identity/backend/api/v1/__init__.py b/src/govoplan_identity/backend/api/v1/__init__.py new file mode 100644 index 0000000..4ac2fb3 --- /dev/null +++ b/src/govoplan_identity/backend/api/v1/__init__.py @@ -0,0 +1 @@ +"""Identity API v1 package.""" diff --git a/src/govoplan_identity/backend/api/v1/routes.py b/src/govoplan_identity/backend/api/v1/routes.py new file mode 100644 index 0000000..3d74aac --- /dev/null +++ b/src/govoplan_identity/backend/api/v1/routes.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, require_any_scope +from govoplan_core.db.session import get_session +from govoplan_identity.backend.db.models import Identity, IdentityAccountLink + +from .schemas import IdentityItem, IdentityListResponse + + +router = APIRouter(prefix="/identity", tags=["identity"]) + +IDENTITY_READ_SCOPES = ( + "identity:identity:read", + "identity:read", + "admin:users:read", + "system:accounts:read", + "organizations:function:assign", +) + + +@router.get("/identities", response_model=IdentityListResponse) +def list_identities( + query: str | None = Query(default=None, min_length=1, max_length=255), + limit: int = Query(default=25, ge=1, le=100), + include_inactive: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_READ_SCOPES)), +) -> IdentityListResponse: + del principal + identity_query = session.query(Identity) + if not include_inactive: + identity_query = identity_query.filter(Identity.is_active.is_(True)) + if query: + pattern = f"%{query.strip().casefold()}%" + matching_account_links = session.query(IdentityAccountLink.identity_id).filter( + func.lower(IdentityAccountLink.account_id).like(pattern) + ) + identity_query = identity_query.filter( + or_( + func.lower(Identity.id).like(pattern), + func.lower(Identity.display_name).like(pattern), + func.lower(Identity.external_subject).like(pattern), + Identity.id.in_(matching_account_links), + ) + ) + + identities = identity_query.order_by(Identity.display_name.asc(), Identity.id.asc()).limit(limit).all() + identity_ids = [identity.id for identity in identities] + links_by_identity: dict[str, list[IdentityAccountLink]] = {identity_id: [] for identity_id in identity_ids} + if identity_ids: + links = ( + session.query(IdentityAccountLink) + .filter(IdentityAccountLink.identity_id.in_(identity_ids)) + .order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.account_id.asc()) + .all() + ) + for link in links: + links_by_identity.setdefault(link.identity_id, []).append(link) + + return IdentityListResponse( + identities=[ + _identity_item(identity, links_by_identity.get(identity.id, [])) + for identity in identities + ] + ) + + +def _identity_item(identity: Identity, links: list[IdentityAccountLink]) -> IdentityItem: + primary_link = next((link for link in links if link.is_primary), None) + return IdentityItem( + id=identity.id, + display_name=identity.display_name, + external_subject=identity.external_subject, + source=identity.source, + primary_account_id=primary_link.account_id if primary_link is not None else None, + account_ids=[link.account_id for link in links], + status="active" if identity.is_active else "inactive", + ) diff --git a/src/govoplan_identity/backend/api/v1/schemas.py b/src/govoplan_identity/backend/api/v1/schemas.py new file mode 100644 index 0000000..2967f09 --- /dev/null +++ b/src/govoplan_identity/backend/api/v1/schemas.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class IdentityItem(BaseModel): + id: str + display_name: str | None = None + external_subject: str | None = None + source: str + primary_account_id: str | None = None + account_ids: list[str] + status: str + + +class IdentityListResponse(BaseModel): + identities: list[IdentityItem] diff --git a/src/govoplan_identity/backend/manifest.py b/src/govoplan_identity/backend/manifest.py index 5b92583..6920537 100644 --- a/src/govoplan_identity/backend/manifest.py +++ b/src/govoplan_identity/backend/manifest.py @@ -1,12 +1,48 @@ from __future__ import annotations +from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY from govoplan_core.core.module_guards import persistent_table_uninstall_guard -from govoplan_core.core.modules import DocumentationTopic, MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.core.modules import DocumentationTopic, MigrationSpec, ModuleContext, ModuleManifest, PermissionDefinition, RoleTemplate from govoplan_core.db.base import Base from govoplan_identity.backend.db import models as identity_models # noqa: F401 - populate metadata +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Identity", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +PERMISSIONS = ( + _permission("identity:identity:read", "View identities", "Search and read normalized identities and their account links."), +) + +ROLE_TEMPLATES = ( + RoleTemplate( + slug="identity_viewer", + name="Identity viewer", + description="Read normalized identities and account links.", + permissions=("identity:identity:read",), + ), +) + + +def _route_factory(context: ModuleContext): + del context + from govoplan_identity.backend.api.v1.routes import router + + return router + + def _identity_directory(context: ModuleContext) -> object: del context from govoplan_identity.backend.directory import SqlIdentityDirectory @@ -17,7 +53,11 @@ def _identity_directory(context: ModuleContext) -> object: manifest = ModuleManifest( id="identity", name="Identity", - version="0.1.6", + version="0.1.7", + required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + permissions=PERMISSIONS, + role_templates=ROLE_TEMPLATES, + route_factory=_route_factory, migration_spec=MigrationSpec(module_id="identity", metadata=Base.metadata), uninstall_guard_providers=( persistent_table_uninstall_guard(