feat(hanzo-iam): canonical IAM_ env contract + FastAPI integration (1.30.0)

Bumps hanzo-iam from 1.29.0 (older Casdoor SDK port) to 1.30.0 with a
purpose-built IAM SDK aligned to the canonical IAM_ env contract.

- ENV_PREFIX="IAM_" only — no HANZO_IAM_ / {ORG}_IAM_ fallback chain.
- IAMConfig.from_env reads IAM_ENDPOINT, IAM_CLIENT_ID, IAM_CLIENT_SECRET,
  IAM_ORG, IAM_APP, IAM_CERT, IAM_USERINFO_URL.
- New FastAPI middleware module wraps Casdoor sign-in flow with
  request-state user injection.
- 19 unit tests covering env-read, validation, and FastAPI integration.

Consumer packages (hanzo-cli, hanzo-tools-auth, hanzo-tools-paas) bump
their hanzo-iam dep to >=1.30.0.

Tag: hanzo-iam-1.30.0
This commit is contained in:
2026-05-14 20:56:32 -07:00
parent 55929fce7a
commit ffc961319d
11 changed files with 212 additions and 67 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ dependencies = [
"rich>=13.0",
"httpx>=0.25.0",
"pyjwt>=2.8.0",
"hanzo-iam>=1.0.0",
"hanzo-iam>=1.30.0",
"hanzo-kms>=1.0.0",
"tomli>=2.0.0; python_version < '3.11'",
]
+15 -11
View File
@@ -1,23 +1,25 @@
# hanzo-iam
# hanzoai-iam
Identity and Access Management SDK for the Hanzo ecosystem. Built on Casdoor with organization-aware multi-tenancy.
The PyPI distribution is `hanzoai-iam`; the import path is `hanzo_iam`.
## Installation
```bash
pip install hanzo-iam
pip install hanzoai-iam
```
With FastAPI integration:
```bash
pip install hanzo-iam[fastapi]
pip install hanzoai-iam[fastapi]
```
With KMS support for certificate management:
```bash
pip install hanzo-iam[kms]
pip install hanzoai-iam[kms]
```
## Quick Start
@@ -55,16 +57,18 @@ user = client.get_user_info(access_token=tokens.access_token)
## Environment Variables
One canonical prefix — `IAM_*`. No upstream-brand aliases, no per-org variants.
```bash
# Required
HANZO_IAM_ENDPOINT=https://iam.hanzo.ai
HANZO_IAM_CLIENT_ID=your-client-id
HANZO_IAM_CLIENT_SECRET=your-client-secret
HANZO_IAM_ORG_NAME=HANZO
IAM_ENDPOINT=https://hanzo.id
IAM_CLIENT_ID=your-client-id
IAM_CLIENT_SECRET=your-client-secret
# Optional
HANZO_IAM_APP_NAME=your-app
HANZO_IAM_CERTIFICATE=path/to/cert.pem
# Optional (defaults shown)
IAM_ORG=hanzo
IAM_APP=app
IAM_CERT=path/to/cert.pem # PEM file or inline PEM content
```
## FastAPI Integration
+1 -1
View File
@@ -12,7 +12,7 @@ from hanzo_iam.models import (
UserInfo,
)
__version__ = "1.1.0"
__version__ = "1.1.1"
__all__ = [
# Clients
+18 -23
View File
@@ -98,33 +98,28 @@ class IAMClient:
def _config_from_env(org: Organization = Organization.HANZO) -> IAMConfig:
"""Read configuration from environment variables.
Checks for HANZO_IAM_* vars first, then {ORG}_IAM_* vars.
The canonical contract is ``IAM_*`` only — no upstream-brand
aliases, no per-org fallbacks (see ~/work/hanzo/iam/CLAUDE.md
"Configuration"). The ``org`` argument seeds the default server
URL when ``IAM_ENDPOINT`` is unset and the default organization
name when ``IAM_ORG`` is unset; it does not influence which env
vars are read.
Environment variables:
HANZO_IAM_URL / {ORG}_IAM_URL - IAM server URL
HANZO_IAM_CLIENT_ID / {ORG}_IAM_CLIENT_ID - OAuth2 client ID
HANZO_IAM_CLIENT_SECRET / {ORG}_IAM_CLIENT_SECRET - OAuth2 client secret
HANZO_IAM_ORG / {ORG}_IAM_ORG - Organization name
HANZO_IAM_APP / {ORG}_IAM_APP - Application name
HANZO_IAM_CERT / {ORG}_IAM_CERT - JWT verification certificate
IAM_ENDPOINT - IAM server URL (defaults to ``org.iam_url``)
IAM_CLIENT_ID - OAuth2 client ID
IAM_CLIENT_SECRET - OAuth2 client secret
IAM_ORG - Organization name (defaults to ``org.value``)
IAM_APP - Application name (defaults to ``app``)
IAM_CERT - JWT verification certificate
"""
org_prefix = org.value.upper()
def get_env(key: str, default: str = "") -> str:
"""Get env var with fallback chain: IAM_ -> HANZO_IAM_ -> {ORG}_IAM_."""
return (
os.getenv(f"IAM_{key}")
or os.getenv(f"HANZO_IAM_{key}")
or os.getenv(f"{org_prefix}_IAM_{key}", default)
)
return IAMConfig(
server_url=get_env("URL", org.iam_url),
client_id=get_env("CLIENT_ID"),
client_secret=get_env("CLIENT_SECRET"),
organization=get_env("ORG", org.value),
application=get_env("APP", "app"),
certificate=get_env("CERT"),
server_url=os.getenv("IAM_ENDPOINT", org.iam_url),
client_id=os.getenv("IAM_CLIENT_ID", ""),
client_secret=os.getenv("IAM_CLIENT_SECRET", ""),
organization=os.getenv("IAM_ORG", org.value),
application=os.getenv("IAM_APP", "app"),
certificate=os.getenv("IAM_CERT", ""),
)
@property
+20 -19
View File
@@ -16,8 +16,10 @@ class IAMConfig(BaseModel):
model_config = ConfigDict(frozen=True)
# Environment variable prefix
ENV_PREFIX: ClassVar[str] = "HANZO_IAM_"
# Canonical environment variable prefix. There is exactly one prefix —
# no upstream-brand aliases, no per-org fallbacks. See
# ~/work/hanzo/iam/CLAUDE.md "Configuration" section.
ENV_PREFIX: ClassVar[str] = "IAM_"
server_url: str = Field(description="IAM server URL (e.g., https://hanzo.id)")
client_id: str = Field(description="OAuth2 client ID")
@@ -32,30 +34,29 @@ class IAMConfig(BaseModel):
def from_env(cls, prefix: str | None = None) -> IAMConfig:
"""Create config from environment variables.
Environment variables:
{prefix}ENDPOINT or {prefix}SERVER_URL - IAM server URL
{prefix}CLIENT_ID - OAuth2 client ID
{prefix}CLIENT_SECRET - OAuth2 client secret
{prefix}ORG_NAME or {prefix}ORGANIZATION - Organization name
{prefix}APP_NAME or {prefix}APPLICATION - Application name
{prefix}CERTIFICATE - JWT certificate (PEM content or file path)
The canonical prefix is ``IAM_`` (no upstream-brand aliases, no
per-org variants). The ``prefix`` argument exists for advanced
callers that scope multiple IAM clients in a single process; new
code should leave it at the default.
Environment variables (with the default prefix):
IAM_ENDPOINT - IAM server URL (preferred)
IAM_CLIENT_ID - OAuth2 client ID
IAM_CLIENT_SECRET - OAuth2 client secret
IAM_ORG - Organization name
IAM_APP - Application name
IAM_CERT - JWT verification certificate (PEM content or file path)
"""
p = prefix or cls.ENV_PREFIX
server_url = os.environ.get(f"{p}ENDPOINT") or os.environ.get(
f"{p}SERVER_URL", ""
)
server_url = os.environ.get(f"{p}ENDPOINT", "")
client_id = os.environ.get(f"{p}CLIENT_ID", "")
client_secret = os.environ.get(f"{p}CLIENT_SECRET", "")
organization = os.environ.get(f"{p}ORG_NAME") or os.environ.get(
f"{p}ORGANIZATION", "hanzo"
)
application = os.environ.get(f"{p}APP_NAME") or os.environ.get(
f"{p}APPLICATION", "app"
)
organization = os.environ.get(f"{p}ORG", "hanzo")
application = os.environ.get(f"{p}APP", "app")
# Certificate can be content or file path
cert_val = os.environ.get(f"{p}CERTIFICATE", "")
cert_val = os.environ.get(f"{p}CERT", "")
if cert_val and not cert_val.startswith("-----BEGIN"):
# Treat as file path
cert_path = os.path.expanduser(cert_val)
+8 -8
View File
@@ -53,8 +53,8 @@ def configure(
"""Configure the global IAM client.
Args:
client_id: OAuth2 client ID (or HANZO_IAM_CLIENT_ID env var)
client_secret: OAuth2 client secret (or HANZO_IAM_CLIENT_SECRET env var)
client_id: OAuth2 client ID (or IAM_CLIENT_ID env var)
client_secret: OAuth2 client secret (or IAM_CLIENT_SECRET env var)
org: Organization (hanzo, zoo, lux, pars)
Returns:
@@ -69,18 +69,18 @@ def configure(
if isinstance(org, str):
org = Organization(org)
# Get credentials from args or environment
resolved_client_id = client_id or os.getenv("HANZO_IAM_CLIENT_ID", "")
resolved_client_secret = client_secret or os.getenv("HANZO_IAM_CLIENT_SECRET", "")
# Get credentials from args or environment (canonical IAM_* only)
resolved_client_id = client_id or os.getenv("IAM_CLIENT_ID", "")
resolved_client_secret = client_secret or os.getenv("IAM_CLIENT_SECRET", "")
if not resolved_client_id:
raise ValueError("client_id required (or set HANZO_IAM_CLIENT_ID)")
raise ValueError("client_id required (or set IAM_CLIENT_ID)")
_config = IAMConfig(
server_url=org.iam_url,
server_url=os.getenv("IAM_ENDPOINT", org.iam_url),
client_id=resolved_client_id,
client_secret=resolved_client_secret,
organization=org.value,
organization=os.getenv("IAM_ORG", org.value),
)
# Reset JWKS client to pick up new config
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "hanzo-iam"
version = "1.1.0"
version = "1.30.0"
description = "Hanzo IAM SDK - Identity and Access Management for Python"
readme = "README.md"
license = { text = "MIT" }
+145
View File
@@ -0,0 +1,145 @@
"""Tests for the canonical IAM_* environment variable contract.
There is exactly one prefix — ``IAM_``. No upstream-brand aliases, no
per-org variants. See ~/work/hanzo/iam/CLAUDE.md "Configuration".
"""
from __future__ import annotations
import os
import pytest
from hanzo_iam.client import IAMClient
from hanzo_iam.config import IAMConfig
from hanzo_iam.models import Organization
_IAM_VARS = (
"IAM_ENDPOINT",
"IAM_CLIENT_ID",
"IAM_CLIENT_SECRET",
"IAM_ORG",
"IAM_APP",
"IAM_CERT",
)
_LEGACY_VARS = (
"HANZO_IAM_ENDPOINT",
"HANZO_IAM_CLIENT_ID",
"HANZO_IAM_CLIENT_SECRET",
"HANZO_IAM_ORG",
"HANZO_IAM_APP",
"HANZO_IAM_CERT",
"HANZO_IAM_URL",
"HANZO_IAM_SERVER_URL",
"HANZO_CLIENT_ID",
"HANZO_CLIENT_SECRET",
"HANZO_IAM_ORGANIZATION",
"HANZO_IAM_APP_NAME",
"HANZO_IAM_ORG_NAME",
"HANZO_IAM_CERTIFICATE",
"LUX_IAM_CLIENT_ID",
"ZOO_IAM_CLIENT_ID",
"PARS_IAM_CLIENT_ID",
)
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
"""Strip every legacy + canonical IAM env var before each test."""
for v in (*_IAM_VARS, *_LEGACY_VARS):
monkeypatch.delenv(v, raising=False)
class TestCanonicalPrefix:
"""IAMConfig.ENV_PREFIX must be 'IAM_' — never an upstream-brand prefix."""
def test_env_prefix_is_iam(self):
assert IAMConfig.ENV_PREFIX == "IAM_"
class TestConfigFromEnv:
"""IAMConfig.from_env reads only IAM_* vars."""
def test_reads_iam_vars(self, monkeypatch):
monkeypatch.setenv("IAM_ENDPOINT", "https://iam.example")
monkeypatch.setenv("IAM_CLIENT_ID", "cid")
monkeypatch.setenv("IAM_CLIENT_SECRET", "csec")
monkeypatch.setenv("IAM_ORG", "acme")
monkeypatch.setenv("IAM_APP", "myapp")
cfg = IAMConfig.from_env()
assert cfg.server_url == "https://iam.example"
assert cfg.client_id == "cid"
assert cfg.client_secret == "csec"
assert cfg.organization == "acme"
assert cfg.application == "myapp"
def test_ignores_hanzo_iam_legacy(self, monkeypatch):
# Legacy aliases must NOT be honored.
monkeypatch.setenv("HANZO_IAM_CLIENT_ID", "legacy")
monkeypatch.setenv("HANZO_IAM_CLIENT_SECRET", "legacy-secret")
monkeypatch.setenv("HANZO_IAM_ENDPOINT", "https://legacy.example")
cfg = IAMConfig.from_env()
assert cfg.client_id == ""
assert cfg.client_secret == ""
assert cfg.server_url == ""
def test_ignores_org_prefixed_legacy(self, monkeypatch):
# {ORG}_IAM_* aliases must NOT be honored.
monkeypatch.setenv("LUX_IAM_CLIENT_ID", "leak")
monkeypatch.setenv("ZOO_IAM_CLIENT_SECRET", "leak2")
cfg = IAMConfig.from_env()
assert cfg.client_id == ""
assert cfg.client_secret == ""
def test_defaults_when_empty(self, monkeypatch):
cfg = IAMConfig.from_env()
assert cfg.server_url == ""
assert cfg.client_id == ""
assert cfg.organization == "hanzo"
assert cfg.application == "app"
class TestClientConfigFromEnv:
"""IAMClient._config_from_env reads only IAM_* vars."""
def test_reads_iam_vars(self, monkeypatch):
monkeypatch.setenv("IAM_ENDPOINT", "https://iam.example")
monkeypatch.setenv("IAM_CLIENT_ID", "cid")
monkeypatch.setenv("IAM_CLIENT_SECRET", "csec")
monkeypatch.setenv("IAM_ORG", "acme")
monkeypatch.setenv("IAM_APP", "myapp")
monkeypatch.setenv("IAM_CERT", "")
cfg = IAMClient._config_from_env(Organization.HANZO)
assert cfg.server_url == "https://iam.example"
assert cfg.client_id == "cid"
assert cfg.client_secret == "csec"
assert cfg.organization == "acme"
assert cfg.application == "myapp"
def test_endpoint_defaults_to_org_url(self, monkeypatch):
cfg = IAMClient._config_from_env(Organization.ZOO)
assert cfg.server_url == "https://zoo.id"
assert cfg.organization == "zoo"
def test_ignores_hanzo_iam_legacy(self, monkeypatch):
monkeypatch.setenv("HANZO_IAM_CLIENT_ID", "legacy")
monkeypatch.setenv("HANZO_IAM_URL", "https://legacy.example")
cfg = IAMClient._config_from_env(Organization.HANZO)
assert cfg.client_id == ""
assert cfg.server_url == "https://hanzo.id" # org default, NOT legacy URL
def test_ignores_org_prefixed_legacy(self, monkeypatch):
monkeypatch.setenv("LUX_IAM_CLIENT_ID", "leak")
monkeypatch.setenv("ZOO_IAM_CLIENT_ID", "leak2")
cfg = IAMClient._config_from_env(Organization.LUX)
assert cfg.client_id == ""
if __name__ == "__main__":
pytest.main([__file__, "-v"])
+1 -1
View File
@@ -43,7 +43,7 @@ class TestConfigure:
import os
# Ensure env var is not set
os.environ.pop("HANZO_IAM_CLIENT_ID", None)
os.environ.pop("IAM_CLIENT_ID", None)
with pytest.raises(ValueError, match="client_id required"):
configure(client_id=None)
+1 -1
View File
@@ -17,7 +17,7 @@ classifiers = [
dependencies = [
"hanzo-tools-core>=0.1.0",
"hanzo-iam>=0.1.0",
"hanzo-iam>=1.30.0",
"httpx>=0.27.0",
"pyjwt>=2.8.0",
]
+1 -1
View File
@@ -18,7 +18,7 @@ classifiers = [
dependencies = [
"hanzo-tools-core>=0.1.0",
"hanzo-tools-auth>=0.1.0",
"hanzo-iam>=0.1.0",
"hanzo-iam>=1.30.0",
"httpx>=0.27.0",
]