commit 4f4d05db42ccf476afac833b85920bec1f6191f6 Author: Albrecht Degering Date: Fri Jul 10 11:01:37 2026 +0200 Initialize identity module diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3455aa9 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/identity +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..3fbb4e1 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/identity + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8e065ff --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/identity +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..397c876 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/identity +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..d3ab26c --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/identity +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7082bff --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +build/ +dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0353cd8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,27 @@ +# GovOPlaN Identity Codex Guide + +## Scope + +This repository owns the canonical GovOPlaN identity directory: internal +identity records and links between identities and platform accounts. + +The module does not own authentication sessions, passwords, API keys, roles, +permissions, or external IDM connector behavior. + +## Boundaries + +- Depend on kernel contracts from `govoplan-core`. +- Do not import access, IDM, organizations, postbox, workflow, or portal + internals. +- Expose integration through manifests, capabilities, API routes, events, and + typed DTOs. +- Keep external-provider synchronization in `govoplan-idm`; this module stores + accepted normalized identity facts. + +## Local Workflow + +Use Gitea issues as the canonical backlog and state log. The shared workflow is +installed under `.gitea/`, with labels in `docs/gitea-labels.json`. + +Focused verification is still driven from `/mnt/DATA/git/govoplan-core` for +cross-repository behavior. diff --git a/README.md b/README.md new file mode 100644 index 0000000..17c3908 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# GovOPlaN Identity + +`govoplan-identity` is the canonical identity directory module for GovOPlaN. + +It owns identities and links between identities and platform accounts. This is +separate from IDM connectors and from access control. + +## Boundary + +Identity owns: + +- identity records for people, services, and other internal subjects +- account links for identities that can use multiple accounts +- primary account selection for display and explainability +- accepted normalized identity facts after import or manual administration + +Identity does not own: + +- password authentication, sessions, API keys, or CSRF +- roles, rights, permission evaluation, or access decisions +- organization units, functions, or function assignments +- external directory connector internals + +## Module Contract + +The module registers the `identity.directory` capability from +`govoplan_core.core.identity`. + +Feature modules should consume the capability instead of importing identity ORM +models. + +## Development Install + +From the core checkout: + +```bash +cd /mnt/DATA/git/govoplan-core +./.venv/bin/python -m pip install -e ../govoplan-identity +``` diff --git a/docs/IDENTITY_MODEL.md b/docs/IDENTITY_MODEL.md new file mode 100644 index 0000000..2bf03ab --- /dev/null +++ b/docs/IDENTITY_MODEL.md @@ -0,0 +1,27 @@ +# Identity Model + +The identity model answers who the platform subject is, independent of how they +authenticate and independent of what they may do. + +## Layers + +- Identity: a person, service, or other subject known to GovOPlaN. +- Account link: a reference from that identity to one platform account. +- Primary account: the account used as the default display/explainability + anchor when multiple accounts exist. + +## Boundary With Access + +Access owns authorization. Identity only tells access which identity is behind +an account. Access can then evaluate roles, rights, delegation, and policy. + +## Boundary With Organizations + +Organization functions and function assignments live in +`govoplan-organizations`. Those assignments may reference identity and account +IDs, but identity does not own organizational structure. + +## Boundary With IDM + +`govoplan-idm` imports, previews, reconciles, and applies external identity +facts. Once accepted, normalized identity records belong here. diff --git a/docs/gitea-labels.json b/docs/gitea-labels.json new file mode 100644 index 0000000..74dceb9 --- /dev/null +++ b/docs/gitea-labels.json @@ -0,0 +1,416 @@ +[ + { + "name": "type/bug", + "color": "d73a4a", + "description": "A reproducible defect, regression, or incorrect behavior.", + "exclusive": true + }, + { + "name": "type/feature", + "color": "0e8a16", + "description": "New user-visible behavior or platform capability.", + "exclusive": true + }, + { + "name": "type/task", + "color": "1d76db", + "description": "Implementation, maintenance, migration, or operational work.", + "exclusive": true + }, + { + "name": "type/debt", + "color": "fbca04", + "description": "Cleanup, refactoring, risk reduction, or deferred engineering work.", + "exclusive": true + }, + { + "name": "type/docs", + "color": "5319e7", + "description": "Documentation, process, or developer workflow work.", + "exclusive": true + }, + { + "name": "priority/p0", + "color": "b60205", + "description": "Immediate stop-the-line priority.", + "exclusive": true + }, + { + "name": "priority/p1", + "color": "d93f0b", + "description": "High priority for the next focused work window.", + "exclusive": true + }, + { + "name": "priority/p2", + "color": "fbca04", + "description": "Normal planned priority.", + "exclusive": true + }, + { + "name": "priority/p3", + "color": "c2e0c6", + "description": "Low priority or opportunistic cleanup.", + "exclusive": true + }, + { + "name": "status/triage", + "color": "d4c5f9", + "description": "Needs review, ownership, priority, or acceptance criteria.", + "exclusive": true + }, + { + "name": "status/ready", + "color": "0e8a16", + "description": "Ready for implementation.", + "exclusive": true + }, + { + "name": "status/in-progress", + "color": "0052cc", + "description": "Currently being worked.", + "exclusive": true + }, + { + "name": "status/blocked", + "color": "b60205", + "description": "Cannot progress without a decision, dependency, credential, or external change.", + "exclusive": true + }, + { + "name": "status/needs-info", + "color": "f9d0c4", + "description": "Needs clarifying input before implementation can proceed safely.", + "exclusive": true + }, + { + "name": "module/access", + "color": "0e8a16", + "description": "GovOPlaN access, identity, authentication, RBAC, and administration behavior.", + "exclusive": false + }, + { + "name": "module/addresses", + "color": "5319e7", + "description": "GovOPlaN Addresses module behavior or integration.", + "exclusive": false + }, + { + "name": "module/admin", + "color": "006b75", + "description": "GovOPlaN Admin module behavior or integration.", + "exclusive": false + }, + { + "name": "module/appointments", + "color": "d876e3", + "description": "GovOPlaN Appointments module behavior or integration.", + "exclusive": false + }, + { + "name": "module/audit", + "color": "0e8a16", + "description": "GovOPlaN Audit module behavior or integration.", + "exclusive": false + }, + { + "name": "module/calendar", + "color": "bfd4f2", + "description": "GovOPlaN Calendar module behavior or integration.", + "exclusive": false + }, + { + "name": "module/campaign", + "color": "d876e3", + "description": "GovOPlaN campaign module behavior or integration.", + "exclusive": false + }, + { + "name": "module/cases", + "color": "1d76db", + "description": "GovOPlaN Cases module behavior or integration.", + "exclusive": false + }, + { + "name": "module/connectors", + "color": "c2e0c6", + "description": "GovOPlaN Connectors module behavior or integration.", + "exclusive": false + }, + { + "name": "module/core", + "color": "0052cc", + "description": "GovOPlaN core runner, shared primitives, shell, or extension points.", + "exclusive": false + }, + { + "name": "module/dms", + "color": "c5def5", + "description": "GovOPlaN Dms module behavior or integration.", + "exclusive": false + }, + { + "name": "module/erp", + "color": "fef2c0", + "description": "GovOPlaN Erp module behavior or integration.", + "exclusive": false + }, + { + "name": "module/files", + "color": "006b75", + "description": "GovOPlaN files module behavior or integration.", + "exclusive": false + }, + { + "name": "module/fit-connect", + "color": "fbca04", + "description": "GovOPlaN Fit Connect module behavior or integration.", + "exclusive": false + }, + { + "name": "module/forms", + "color": "f9d0c4", + "description": "GovOPlaN Forms module behavior or integration.", + "exclusive": false + }, + { + "name": "module/identity-trust", + "color": "d4c5f9", + "description": "GovOPlaN Identity Trust module behavior or integration.", + "exclusive": false + }, + { + "name": "module/identity", + "color": "bfd4f2", + "description": "GovOPlaN Identity module behavior or integration.", + "exclusive": false + }, + { + "name": "module/idm", + "color": "0052cc", + "description": "GovOPlaN Idm module behavior or integration.", + "exclusive": false + }, + { + "name": "module/ledger", + "color": "5319e7", + "description": "GovOPlaN Ledger module behavior or integration.", + "exclusive": false + }, + { + "name": "module/mail", + "color": "5319e7", + "description": "GovOPlaN mail module behavior or integration.", + "exclusive": false + }, + { + "name": "module/notifications", + "color": "d876e3", + "description": "GovOPlaN Notifications module behavior or integration.", + "exclusive": false + }, + { + "name": "module/ops", + "color": "0e8a16", + "description": "GovOPlaN Ops module behavior or integration.", + "exclusive": false + }, + { + "name": "module/organizations", + "color": "bfdadc", + "description": "GovOPlaN Organizations module behavior or integration.", + "exclusive": false + }, + { + "name": "module/payments", + "color": "bfd4f2", + "description": "GovOPlaN Payments module behavior or integration.", + "exclusive": false + }, + { + "name": "module/policy", + "color": "d93f0b", + "description": "GovOPlaN Policy module behavior or integration.", + "exclusive": false + }, + { + "name": "module/portal", + "color": "1d76db", + "description": "GovOPlaN Portal module behavior or integration.", + "exclusive": false + }, + { + "name": "module/postbox", + "color": "d93f0b", + "description": "GovOPlaN Postbox module behavior or integration.", + "exclusive": false + }, + { + "name": "module/reporting", + "color": "c2e0c6", + "description": "GovOPlaN Reporting module behavior or integration.", + "exclusive": false + }, + { + "name": "module/search", + "color": "bfdadc", + "description": "GovOPlaN Search module behavior or integration.", + "exclusive": false + }, + { + "name": "module/scheduling", + "color": "d93f0b", + "description": "GovOPlaN Scheduling module behavior or integration.", + "exclusive": false + }, + { + "name": "module/tasks", + "color": "c5def5", + "description": "GovOPlaN Tasks module behavior or integration.", + "exclusive": false + }, + { + "name": "module/templates", + "color": "fef2c0", + "description": "GovOPlaN Templates module behavior or integration.", + "exclusive": false + }, + { + "name": "module/tenancy", + "color": "e4e669", + "description": "GovOPlaN Tenancy module behavior or integration.", + "exclusive": false + }, + { + "name": "module/web", + "color": "bfd4f2", + "description": "GovOPlaN public website, product page, or publication content.", + "exclusive": false + }, + { + "name": "module/workflow", + "color": "f9d0c4", + "description": "GovOPlaN Workflow module behavior or integration.", + "exclusive": false + }, + { + "name": "module/xoev", + "color": "d4c5f9", + "description": "GovOPlaN Xoev module behavior or integration.", + "exclusive": false + }, + { + "name": "module/xrechnung", + "color": "0052cc", + "description": "GovOPlaN Xrechnung module behavior or integration.", + "exclusive": false + }, + { + "name": "module/xta-osci", + "color": "5319e7", + "description": "GovOPlaN Xta Osci module behavior or integration.", + "exclusive": false + }, + { + "name": "area/auth", + "color": "bfd4f2", + "description": "Authentication, sessions, access bootstrap, or login behavior.", + "exclusive": false + }, + { + "name": "area/tenancy", + "color": "bfdadc", + "description": "Tenant boundaries, provisioning, or tenant-scoped data behavior.", + "exclusive": false + }, + { + "name": "area/rbac", + "color": "c5def5", + "description": "Permissions, roles, delegation, or authorization policy.", + "exclusive": false + }, + { + "name": "area/governance", + "color": "fef2c0", + "description": "Governance policy, audit, privacy, retention, or compliance behavior.", + "exclusive": false + }, + { + "name": "area/module-system", + "color": "bfe5bf", + "description": "Module discovery, manifests, capabilities, routing, or optional integrations.", + "exclusive": false + }, + { + "name": "area/migrations", + "color": "e4e669", + "description": "Alembic migrations, schema bootstrap, or persistence evolution.", + "exclusive": false + }, + { + "name": "area/webui", + "color": "1d76db", + "description": "Shared WebUI shell, frontend components, routing, or frontend tests.", + "exclusive": false + }, + { + "name": "area/api", + "color": "5319e7", + "description": "HTTP API contracts, routers, schemas, or API smoke behavior.", + "exclusive": false + }, + { + "name": "area/db", + "color": "0e8a16", + "description": "Database sessions, models, transactions, or persistence primitives.", + "exclusive": false + }, + { + "name": "area/devex", + "color": "bfd4f2", + "description": "Local developer workflow, scripts, tests, tooling, or release helpers.", + "exclusive": false + }, + { + "name": "area/release", + "color": "c2e0c6", + "description": "Versioning, release locks, tags, packaging, or dependency pins.", + "exclusive": false + }, + { + "name": "area/docs", + "color": "5319e7", + "description": "Durable documentation and project guidance.", + "exclusive": false + }, + { + "name": "area/marketing", + "color": "f9d0c4", + "description": "Public website, product messaging, publication copy, or legal page content.", + "exclusive": false + }, + { + "name": "source/todo-scan", + "color": "cccccc", + "description": "Imported from inline TODO/FIXME/HACK markers by the Gitea TODO importer.", + "exclusive": false + }, + { + "name": "source/backlog-import", + "color": "bfdadc", + "description": "Imported from markdown backlog, roadmap, plan, or TODO files.", + "exclusive": false + }, + { + "name": "codex/ready", + "color": "0e8a16", + "description": "Suitable for Codex to pick up with the existing issue context.", + "exclusive": false + }, + { + "name": "codex/needs-human", + "color": "f9d0c4", + "description": "Needs an explicit human decision before Codex should implement.", + "exclusive": false + } +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2393359 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-identity" +version = "0.1.6" +description = "GovOPlaN identity directory module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.6", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_identity = ["py.typed"] + +[project.entry-points."govoplan.modules"] +identity = "govoplan_identity.backend.manifest:get_manifest" diff --git a/src/govoplan_identity/__init__.py b/src/govoplan_identity/__init__.py new file mode 100644 index 0000000..8e13f17 --- /dev/null +++ b/src/govoplan_identity/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN identity module.""" + +__version__ = "0.1.6" diff --git a/src/govoplan_identity/backend/__init__.py b/src/govoplan_identity/backend/__init__.py new file mode 100644 index 0000000..b0523ac --- /dev/null +++ b/src/govoplan_identity/backend/__init__.py @@ -0,0 +1 @@ +"""Backend integration for the GovOPlaN identity module.""" diff --git a/src/govoplan_identity/backend/db/__init__.py b/src/govoplan_identity/backend/db/__init__.py new file mode 100644 index 0000000..418d1c2 --- /dev/null +++ b/src/govoplan_identity/backend/db/__init__.py @@ -0,0 +1 @@ +"""Identity database models.""" diff --git a/src/govoplan_identity/backend/db/models.py b/src/govoplan_identity/backend/db/models.py new file mode 100644 index 0000000..8acfffd --- /dev/null +++ b/src/govoplan_identity/backend/db/models.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import Boolean, ForeignKey, Index, JSON, String, UniqueConstraint, text +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class CanonicalIdentity(Base, TimestampMixin): + __tablename__ = "identity_identities" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + display_name: Mapped[str | None] = mapped_column(String(255)) + external_subject: Mapped[str | None] = mapped_column(String(255), index=True) + source: Mapped[str] = mapped_column(String(50), default="local", nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class CanonicalIdentityAccountLink(Base, TimestampMixin): + __tablename__ = "identity_account_links" + __table_args__ = ( + UniqueConstraint("identity_id", "account_id", name="uq_identity_module_account_links_identity_account"), + Index( + "uq_identity_module_account_links_primary_account", + "account_id", + unique=True, + sqlite_where=text("is_primary = 1"), + postgresql_where=text("is_primary IS TRUE"), + ), + Index( + "uq_identity_module_account_links_primary_identity", + "identity_id", + unique=True, + sqlite_where=text("is_primary = 1"), + postgresql_where=text("is_primary IS TRUE"), + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + identity_id: Mapped[str] = mapped_column(ForeignKey("identity_identities.id", ondelete="CASCADE"), nullable=False, index=True) + account_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + source: Mapped[str] = mapped_column(String(50), default="local", nullable=False) + + +Identity = CanonicalIdentity +IdentityAccountLink = CanonicalIdentityAccountLink + + +__all__ = ["CanonicalIdentity", "CanonicalIdentityAccountLink", "Identity", "IdentityAccountLink", "new_uuid"] diff --git a/src/govoplan_identity/backend/directory.py b/src/govoplan_identity/backend/directory.py new file mode 100644 index 0000000..ddb6508 --- /dev/null +++ b/src/govoplan_identity/backend/directory.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from govoplan_core.core.identity import IdentityAccountLinkRef, IdentityDirectory, IdentityRef +from govoplan_core.db.session import get_database +from govoplan_identity.backend.db.models import Identity, IdentityAccountLink + + +def _status(active: bool) -> str: + return "active" if active else "inactive" + + +def _identity_ref(identity: Identity, links: Sequence[IdentityAccountLink]) -> IdentityRef: + primary_account_id = next((link.account_id for link in links if link.is_primary), None) + return IdentityRef( + id=identity.id, + display_name=identity.display_name, + external_subject=identity.external_subject, + source=identity.source, + primary_account_id=primary_account_id, + account_ids=tuple(link.account_id for link in links), + status=_status(identity.is_active), # type: ignore[arg-type] + ) + + +def _link_ref(link: IdentityAccountLink) -> IdentityAccountLinkRef: + return IdentityAccountLinkRef( + id=link.id, + identity_id=link.identity_id, + account_id=link.account_id, + is_primary=link.is_primary, + source=link.source, + ) + + +class SqlIdentityDirectory(IdentityDirectory): + def get_identity(self, identity_id: str) -> IdentityRef | None: + with get_database().session() as session: + identity = session.get(Identity, identity_id) + if identity is None: + return None + links = ( + session.query(IdentityAccountLink) + .filter(IdentityAccountLink.identity_id == identity.id) + .order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc()) + .all() + ) + return _identity_ref(identity, links) + + def identity_for_account(self, account_id: str) -> IdentityRef | None: + with get_database().session() as session: + link = ( + session.query(IdentityAccountLink) + .filter(IdentityAccountLink.account_id == account_id) + .order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc()) + .first() + ) + if link is None: + return None + identity = session.get(Identity, link.identity_id) + if identity is None: + return None + links = ( + session.query(IdentityAccountLink) + .filter(IdentityAccountLink.identity_id == identity.id) + .order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc()) + .all() + ) + return _identity_ref(identity, links) + + def identities_for_accounts(self, account_ids: Sequence[str]) -> tuple[IdentityRef, ...]: + ids = sorted({str(account_id) for account_id in account_ids if account_id}) + if not ids: + return () + with get_database().session() as session: + identity_ids = [ + row[0] + for row in session.query(IdentityAccountLink.identity_id) + .filter(IdentityAccountLink.account_id.in_(ids)) + .distinct() + .all() + ] + identities = session.query(Identity).filter(Identity.id.in_(identity_ids)).all() if identity_ids else [] + links = ( + session.query(IdentityAccountLink) + .filter(IdentityAccountLink.identity_id.in_(identity_ids)) + .order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc()) + .all() + if identity_ids else [] + ) + links_by_identity: dict[str, list[IdentityAccountLink]] = {} + for link in links: + links_by_identity.setdefault(link.identity_id, []).append(link) + return tuple(_identity_ref(identity, links_by_identity.get(identity.id, [])) for identity in identities) + + def accounts_for_identity(self, identity_id: str) -> tuple[IdentityAccountLinkRef, ...]: + with get_database().session() as session: + links = ( + session.query(IdentityAccountLink) + .filter(IdentityAccountLink.identity_id == identity_id) + .order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc()) + .all() + ) + return tuple(_link_ref(link) for link in links) diff --git a/src/govoplan_identity/backend/manifest.py b/src/govoplan_identity/backend/manifest.py new file mode 100644 index 0000000..5b92583 --- /dev/null +++ b/src/govoplan_identity/backend/manifest.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +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.db.base import Base +from govoplan_identity.backend.db import models as identity_models # noqa: F401 - populate metadata + + +def _identity_directory(context: ModuleContext) -> object: + del context + from govoplan_identity.backend.directory import SqlIdentityDirectory + + return SqlIdentityDirectory() + + +manifest = ModuleManifest( + id="identity", + name="Identity", + version="0.1.6", + migration_spec=MigrationSpec(module_id="identity", metadata=Base.metadata), + uninstall_guard_providers=( + persistent_table_uninstall_guard( + identity_models.Identity, + identity_models.IdentityAccountLink, + label="Identity", + ), + ), + capability_factories={ + CAPABILITY_IDENTITY_DIRECTORY: _identity_directory, + }, + documentation=( + DocumentationTopic( + id="identity.model", + title="Identity directory", + summary="Identity owns normalized subjects and links them to platform accounts. Access owns authorization.", + body=( + "An identity can have multiple accounts. Identity facts remain separate from authentication sessions, " + "organization functions, and permission decisions." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("tenant_admin", "access_admin", "operator"), + order=24, + ), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_identity/py.typed b/src/govoplan_identity/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_identity/py.typed @@ -0,0 +1 @@ +