mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ from hanzo_iam.models import (
|
||||
UserInfo,
|
||||
)
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.1.1"
|
||||
|
||||
__all__ = [
|
||||
# Clients
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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" }
|
||||
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user