5 Commits
v0.1.6 ... main

Author SHA1 Message Date
7a1710af89 Release v0.1.8 2026-07-11 16:49:02 +02:00
99560a74c6 Release v0.1.7 2026-07-11 02:34:57 +02:00
c0afbc9333 Document identity projection migration 2026-07-11 01:13:53 +02:00
1cdc760c2f Add shared GovOPlaN gitignore 2026-07-10 21:57:26 +02:00
d7bf71a2d9 Expose identity lookup API 2026-07-10 18:57:19 +02:00
14 changed files with 646 additions and 8 deletions

267
.gitignore vendored
View File

@@ -6,3 +6,270 @@ __pycache__/
.ruff_cache/ .ruff_cache/
build/ build/
dist/ dist/
# GovOPlaN shared ignore rules from govoplan-core
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Local WebUI test/build scratch directories
.component-test-build/
.module-test-build/
.policy-test-build/
.template-preview-test-build/
.import-test-build/
webui/.component-test-build/
webui/.module-test-build/
webui/.policy-test-build/
webui/.template-preview-test-build/
webui/.import-test-build/
# ---> Python
# Byte-compiled / optimized / DLL files
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
develop-eggs/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
# PyPI configuration file
.pypirc
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
*.db
# GovOPlaN local runtime state
runtime/
# GovOPlaN WebUI test output
webui/.module-test-build/
webui/.component-test-build/

View File

@@ -1,5 +1,9 @@
# GovOPlaN Identity # GovOPlaN Identity
<!-- govoplan-repository-type:start -->
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-identity` is the canonical identity directory module for GovOPlaN. `govoplan-identity` is the canonical identity directory module for GovOPlaN.
It owns identities and links between identities and platform accounts. This is It owns identities and links between identities and platform accounts. This is

View File

@@ -17,11 +17,34 @@ an account. Access can then evaluate roles, rights, delegation, and policy.
## Boundary With Organizations ## Boundary With Organizations
Organization functions and function assignments live in Organization functions live in `govoplan-organizations`.
`govoplan-organizations`. Those assignments may reference identity and account Identity-to-function assignments live in `govoplan-idm`. Those assignments may
IDs, but identity does not own organizational structure. reference identity and account IDs, but identity does not own organizational
structure or assignment workflows.
## Boundary With IDM ## Boundary With IDM
`govoplan-idm` imports, previews, reconciles, and applies external identity `govoplan-idm` imports, previews, reconciles, and applies external identity
facts. Once accepted, normalized identity records belong here. facts. Once accepted, normalized identity records belong here.
## Access Projection Migration
`govoplan-access` now prefers `identity.directory` when it needs to resolve the
identity behind an account or render identity labels in semantic access views.
If the identity module is not installed, Access falls back to the legacy
`access_identities` and `access_identity_account_links` projection tables.
Rollout plan:
- keep the Access projection tables readable until existing installations have
a backfill path;
- backfill `identity_identities` and `identity_account_links` from the Access
projection where Identity is newly installed on an existing deployment;
- keep Access writes that still create local accounts able to maintain the
projection during the compatibility window;
- once deployments use `identity.directory` consistently, retire direct Access
identity reads and leave the projection tables as migration-only data until a
release-level retirement plan removes them.
The close-out condition is that Access works with canonical Identity installed
and still works without it through the projection fallback.

View File

@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-identity" name = "govoplan-identity"
version = "0.1.6" version = "0.1.8"
description = "GovOPlaN identity directory module." description = "GovOPlaN identity directory module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.6", "govoplan-core>=0.1.8",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]

View File

@@ -0,0 +1 @@
"""Identity API package."""

View File

@@ -0,0 +1 @@
"""Identity API v1 package."""

View File

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

View File

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

View File

@@ -1,12 +1,50 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
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.identity import CAPABILITY_IDENTITY_DIRECTORY
from govoplan_core.core.module_guards import persistent_table_uninstall_guard 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_core.db.base import Base
from govoplan_identity.backend.db import models as identity_models # noqa: F401 - populate metadata 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: def _identity_directory(context: ModuleContext) -> object:
del context del context
from govoplan_identity.backend.directory import SqlIdentityDirectory from govoplan_identity.backend.directory import SqlIdentityDirectory
@@ -17,8 +55,17 @@ def _identity_directory(context: ModuleContext) -> object:
manifest = ModuleManifest( manifest = ModuleManifest(
id="identity", id="identity",
name="Identity", name="Identity",
version="0.1.6", version="0.1.8",
migration_spec=MigrationSpec(module_id="identity", metadata=Base.metadata), 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,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
migration_after=("access",),
),
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
identity_models.Identity, identity_models.Identity,

View File

@@ -0,0 +1 @@
"""Identity module migrations."""

View File

@@ -0,0 +1,141 @@
"""identity directory
Revision ID: 5c6d7e8f9a10
Revises: 4a5b6c7d8e9f
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "5c6d7e8f9a10"
down_revision = "4a5b6c7d8e9f"
branch_labels = None
depends_on = None
def _tables() -> set[str]:
return set(sa.inspect(op.get_bind()).get_table_names())
def _indexes(table_name: str) -> set[str]:
if table_name not in _tables():
return set()
return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)}
def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False, **kwargs) -> None:
if table_name in _tables() and name not in _indexes(table_name):
op.create_index(name, table_name, columns, unique=unique, **kwargs)
def _drop_index_if_exists(name: str, table_name: str) -> None:
if table_name in _tables() and name in _indexes(table_name):
op.drop_index(name, table_name=table_name)
def upgrade() -> None:
tables = _tables()
if "identity_identities" not in tables:
op.create_table(
"identity_identities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("display_name", sa.String(length=255), nullable=True),
sa.Column("external_subject", sa.String(length=255), nullable=True),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_identity_identities")),
)
_create_index_if_missing(op.f("ix_identity_identities_external_subject"), "identity_identities", ["external_subject"])
tables = _tables()
if "identity_account_links" not in tables:
op.create_table(
"identity_account_links",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=36), nullable=False),
sa.Column("is_primary", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["identity_id"],
["identity_identities.id"],
name=op.f("fk_identity_account_links_identity_id_identity_identities"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_identity_account_links")),
sa.UniqueConstraint("identity_id", "account_id", name="uq_identity_module_account_links_identity_account"),
)
_create_index_if_missing(op.f("ix_identity_account_links_account_id"), "identity_account_links", ["account_id"])
_create_index_if_missing(op.f("ix_identity_account_links_identity_id"), "identity_account_links", ["identity_id"])
_create_index_if_missing(
"uq_identity_module_account_links_primary_account",
"identity_account_links",
["account_id"],
unique=True,
sqlite_where=sa.text("is_primary = 1"),
postgresql_where=sa.text("is_primary IS TRUE"),
)
_create_index_if_missing(
"uq_identity_module_account_links_primary_identity",
"identity_account_links",
["identity_id"],
unique=True,
sqlite_where=sa.text("is_primary = 1"),
postgresql_where=sa.text("is_primary IS TRUE"),
)
tables = _tables()
if {"access_identities", "access_identity_account_links"}.issubset(tables):
op.execute("""
INSERT INTO identity_identities
(id, display_name, external_subject, source, is_active, settings, created_at, updated_at)
SELECT id, display_name, external_subject, source, is_active, settings, created_at, updated_at
FROM access_identities source_identity
WHERE NOT EXISTS (
SELECT 1
FROM identity_identities target_identity
WHERE target_identity.id = source_identity.id
)
""")
op.execute("""
INSERT INTO identity_account_links
(id, identity_id, account_id, is_primary, source, created_at, updated_at)
SELECT id, identity_id, account_id, is_primary, source, created_at, updated_at
FROM access_identity_account_links source_link
WHERE EXISTS (
SELECT 1
FROM identity_identities target_identity
WHERE target_identity.id = source_link.identity_id
)
AND NOT EXISTS (
SELECT 1
FROM identity_account_links target_link
WHERE target_link.id = source_link.id
)
AND NOT EXISTS (
SELECT 1
FROM identity_account_links target_link
WHERE target_link.identity_id = source_link.identity_id
AND target_link.account_id = source_link.account_id
)
""")
def downgrade() -> None:
_drop_index_if_exists("uq_identity_module_account_links_primary_identity", "identity_account_links")
_drop_index_if_exists("uq_identity_module_account_links_primary_account", "identity_account_links")
_drop_index_if_exists(op.f("ix_identity_account_links_identity_id"), "identity_account_links")
_drop_index_if_exists(op.f("ix_identity_account_links_account_id"), "identity_account_links")
if "identity_account_links" in _tables():
op.drop_table("identity_account_links")
_drop_index_if_exists(op.f("ix_identity_identities_external_subject"), "identity_identities")
if "identity_identities" in _tables():
op.drop_table("identity_identities")

View File

@@ -0,0 +1 @@
"""Identity module migration versions."""

View File

@@ -0,0 +1,52 @@
"""v0.1.7 identity baseline
Revision ID: 5c6d7e8f9a10
Revises: None
Create Date: 2026-07-11 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = '5c6d7e8f9a10'
down_revision = None
branch_labels = None
depends_on = ('4f2a9c8e7b6d', '4a5b6c7d8e9f')
def upgrade() -> None:
op.create_table('identity_identities',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('external_subject', sa.String(length=255), nullable=True),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('settings', sa.JSON(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_identity_identities'))
)
op.create_index(op.f('ix_identity_identities_external_subject'), 'identity_identities', ['external_subject'], unique=False)
op.create_table('identity_account_links',
sa.Column('id', sa.String(length=36), nullable=False),
sa.Column('identity_id', sa.String(length=36), nullable=False),
sa.Column('account_id', sa.String(length=36), nullable=False),
sa.Column('is_primary', sa.Boolean(), nullable=False),
sa.Column('source', sa.String(length=50), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['identity_id'], ['identity_identities.id'], name=op.f('fk_identity_account_links_identity_id_identity_identities'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_identity_account_links')),
sa.UniqueConstraint('identity_id', 'account_id', name='uq_identity_module_account_links_identity_account')
)
op.create_index(op.f('ix_identity_account_links_account_id'), 'identity_account_links', ['account_id'], unique=False)
op.create_index(op.f('ix_identity_account_links_identity_id'), 'identity_account_links', ['identity_id'], unique=False)
op.create_index('uq_identity_module_account_links_primary_account', 'identity_account_links', ['account_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
op.create_index('uq_identity_module_account_links_primary_identity', 'identity_account_links', ['identity_id'], unique=True, sqlite_where=sa.text('is_primary = 1'), postgresql_where=sa.text('is_primary IS TRUE'))
def downgrade() -> None:
op.drop_table('identity_account_links')
op.drop_table('identity_identities')

View File

@@ -0,0 +1 @@
"""Identity module migration versions."""