Initialize identity module

This commit is contained in:
2026-07-10 11:01:37 +02:00
commit 4f4d05db42
20 changed files with 923 additions and 0 deletions

View File

@@ -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:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -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?

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
build/
dist/

27
AGENTS.md Normal file
View File

@@ -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.

39
README.md Normal file
View File

@@ -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
```

27
docs/IDENTITY_MODEL.md Normal file
View File

@@ -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.

416
docs/gitea-labels.json Normal file
View File

@@ -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
}
]

23
pyproject.toml Normal file
View File

@@ -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"

View File

@@ -0,0 +1,3 @@
"""GovOPlaN identity module."""
__version__ = "0.1.6"

View File

@@ -0,0 +1 @@
"""Backend integration for the GovOPlaN identity module."""

View File

@@ -0,0 +1 @@
"""Identity database models."""

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1 @@