Merge pull request #1 from hanzoai/batcha/iam-sso-only-per-org

IAM-SSO-only Help Center + per-org SQLite isolation
This commit is contained in:
Hanzo Dev
2026-07-02 07:48:17 -07:00
committed by GitHub
4 changed files with 131 additions and 36 deletions
+14 -4
View File
@@ -25,12 +25,22 @@ is reused; only the app layer swaps.
(Hanzo Base/SQLite). No MariaDB / Postgres / external DB.
## IAM SSO
IAM application: `hanzo-helpdesk` (org `hanzo`) on hanzo.id.
IAM application: `hanzo-helpdesk` on hanzo.id. `IAM_CLIENT_ID=hanzo-helpdesk`.
OAuth2 redirect URI to register:
`https://help.hanzo.ai/api/method/frappe.integrations.oauth2_logins.custom/hanzo`
Endpoints (Casdoor-mapped): authorize `/v1/iam/login/oauth/authorize`,
token `/v1/iam/login/oauth/access_token`, userinfo `/v1/iam/userinfo`,
jwks `/v1/iam/.well-known/jwks`.
Endpoints (authoritative — from hanzo.id `/.well-known/openid-configuration`):
authorize `/v1/iam/oauth/authorize`, token `/v1/iam/oauth/token`,
userinfo `/v1/iam/oauth/userinfo`, jwks `/v1/iam/.well-known/jwks`.
`hanzo_sso.setup` also DISABLES native signup (Website Settings `disable_signup`)
and forces SSO-only login (System Settings `disable_user_pass_login`, v15+),
so IAM is the ONE identity authority — no second signup path.
## Multi-tenant isolation (org == tenant)
One SQLite site (== one DB) per org, resolved by the Host header:
primary org (`HANZO_ORG`) -> `SITE_NAME` (help.hanzo.ai); any other org
`<org>` -> `<org>.<SITE_NAME>`. Provision list = `HANZO_ORGS` (comma-separated).
Isolation is structural: org A's tickets live in a different SQLite file than
org B's, so A's tickets are never visible to B.
## Build & deploy (one lifecycle)
1. arcd BuildKit Job -> `ghcr.io/hanzoai/helpdesk:<semver>` (context =
+50 -21
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env bash
# Hanzo Help Center runtime entrypoint.
# org == tenant: one SQLite site per org, persisted on the mounted volume.
# org == tenant: ONE SQLite site (== one DB) per org, persisted on the mounted
# volume. Frappe resolves the site from the Host header, so per-org DB isolation
# is structural: org A's tickets live in a different SQLite file than org B's.
# primary org (HANZO_ORG) -> host SITE_NAME (e.g. help.hanzo.ai)
# any other org <org> -> host <org>.<SITE_NAME> (e.g. acme.help.hanzo.ai)
# No nginx (hanzoai/ingress terminates TLS + routes the host), no external DB.
set -euo pipefail
@@ -9,8 +13,21 @@ cd "$BENCH_DIR"
SITE_NAME="${SITE_NAME:-help.hanzo.ai}"
HANZO_ORG="${HANZO_ORG:-hanzo}"
# Comma-separated org slugs to provision sites for (org == tenant). The primary
# org is always included. Additional orgs get <org>.<SITE_NAME> sites.
HANZO_ORGS="${HANZO_ORGS:-$HANZO_ORG}"
: "${ADMIN_PASSWORD:?ADMIN_PASSWORD required (from KMS-backed secret help-secrets)}"
# Map an org slug to its Frappe site (== Host header == SQLite DB directory).
site_for_org() {
local org="$1"
if [ "$org" = "$HANZO_ORG" ]; then
echo "$SITE_NAME"
else
echo "${org}.${SITE_NAME}"
fi
}
# 1) In-pod redis for cache/queue/socketio. SQLite holds all relational data.
redis-server --daemonize yes --port 6379 --save "" --appendonly no
@@ -36,28 +53,40 @@ bench set-config -g redis_queue "redis://127.0.0.1:6379" >/dev/null
bench set-config -g redis_socketio "redis://127.0.0.1:6379" >/dev/null
bench set-config -gp socketio_port 9000 >/dev/null
# 4) Per-org SQLite site (org == tenant), created once, persisted on the volume.
if [ ! -d "sites/${SITE_NAME}" ]; then
echo "[entrypoint] creating SQLite site ${SITE_NAME} (org=${HANZO_ORG})"
bench new-site "${SITE_NAME}" \
--db-type sqlite \
--admin-password "${ADMIN_PASSWORD}" \
--install-app telephony \
--install-app helpdesk \
--set-default
[ -n "${ENCRYPTION_KEY:-}" ] && bench --site "${SITE_NAME}" set-config encryption_key "${ENCRYPTION_KEY}"
bench --site "${SITE_NAME}" set-config host_name "https://${SITE_NAME}"
else
echo "[entrypoint] site ${SITE_NAME} exists -> migrate"
bench --site "${SITE_NAME}" migrate
fi
bench use "${SITE_NAME}"
# 4) Per-org SQLite site (org == tenant). Created once, persisted on the volume,
# migrated + (re)hardened on every boot.
provision_site() {
local org="$1" site
site="$(site_for_org "$org")"
if [ ! -d "sites/${site}" ]; then
echo "[entrypoint] creating SQLite site ${site} (org=${org})"
bench new-site "${site}" \
--db-type sqlite \
--admin-password "${ADMIN_PASSWORD}" \
--install-app telephony \
--install-app helpdesk
[ -n "${ENCRYPTION_KEY:-}" ] && bench --site "${site}" set-config encryption_key "${ENCRYPTION_KEY}"
bench --site "${site}" set-config host_name "https://${site}"
bench --site "${site}" set-config hanzo_org "${org}"
else
echo "[entrypoint] site ${site} exists (org=${org}) -> migrate"
bench --site "${site}" migrate
fi
# IAM SSO + login hardening (idempotent, secrets from env / KMS-backed).
bench --site "${site}" execute helpdesk.hanzo_sso.setup \
|| echo "[entrypoint] SSO provisioning skipped/failed for ${site} (non-fatal)"
}
# 5) IAM SSO (hanzo.id OAuth2) — idempotent, secrets from env (KMS-backed).
bench --site "${SITE_NAME}" execute helpdesk.hanzo_sso.setup \
|| echo "[entrypoint] SSO provisioning skipped/failed (non-fatal)"
IFS=',' read -ra _orgs <<< "$HANZO_ORGS"
for _org in "${_orgs[@]}"; do
_org="$(echo "$_org" | tr -d '[:space:]')"
[ -n "$_org" ] && provision_site "$_org"
done
# 6) Serve. bench serve (werkzeug) serves /assets, /files, /api and proxies
# Primary org is the default site for bare/unknown hosts.
bench use "$(site_for_org "$HANZO_ORG")"
# 5) Serve. bench serve (werkzeug) serves /assets, /files, /api and proxies
# /socket.io. Background worker + scheduler + socketio for full function.
echo "[entrypoint] worker + scheduler + socketio + web(:8000) starting"
nohup bench worker --queue default,short,long >/tmp/worker.log 2>&1 &
+7 -4
View File
@@ -11,7 +11,7 @@ spec:
partOf: platform
image:
repository: ghcr.io/hanzoai/helpdesk
tag: 1.26.2 # semver; pinned by universe, never a floating tag
tag: 1.26.4 # semver; pinned by universe, never a floating tag
pullPolicy: Always
imagePullSecrets:
- name: ghcr-secret
@@ -21,7 +21,9 @@ spec:
env:
- name: SITE_NAME
value: help.hanzo.ai
- name: HANZO_ORG # org == tenant (IAM owner)
- name: HANZO_ORG # primary org == tenant (IAM owner)
value: hanzo
- name: HANZO_ORGS # comma-separated org slugs -> one SQLite site each
value: hanzo
- name: IAM_BASE_URL
value: https://hanzo.id
@@ -38,8 +40,9 @@ spec:
- name: IAM_CLIENT_ID
valueFrom:
secretKeyRef: { name: help-secrets, key: IAM_CLIENT_ID }
# IAM_CLIENT_SECRET added once the hanzo-helpdesk IAM app is seeded
# (init_data.json). hanzo_sso.setup skips gracefully until then.
- name: IAM_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: help-secrets, key: IAM_CLIENT_SECRET }
ports:
- name: http
containerPort: 8000
+60 -7
View File
@@ -1,13 +1,20 @@
"""Hanzo IAM (hanzo.id) OAuth2 SSO provisioning for Frappe Helpdesk.
Idempotently upserts a Frappe "Social Login Key" pointing at the Hanzo IAM
OIDC provider. Client credentials come from the environment (a KMS-backed
Kubernetes secret), never hardcoded. Called by the container entrypoint via
Makes Hanzo IAM the ONE identity authority for the Help Center:
1. Idempotently upserts a Frappe "Social Login Key" for the Hanzo IAM OIDC
provider (endpoints match hanzo.id's published OIDC discovery document).
2. Disables Frappe's native email/password signup (so IAM is the only way in;
no second identity authority).
3. Forces SSO-only login where the Frappe version supports it.
Client credentials come from the environment (a KMS-backed Kubernetes secret
`help-secrets`), never hardcoded. Called by the container entrypoint via
`bench --site <site> execute helpdesk.hanzo_sso.setup`.
Frappe's OAuth callback for a custom provider named "hanzo" is:
https://<site>/api/method/frappe.integrations.oauth2_logins.custom/hanzo
which is the redirect URI to register on the IAM application (hanzo-helpdesk).
which is the redirect URI registered on the IAM application (hanzo-helpdesk).
"""
import json
@@ -17,8 +24,23 @@ import frappe
PROVIDER = "hanzo"
# hanzo.id OIDC endpoints — authoritative values from
# https://hanzo.id/.well-known/openid-configuration
AUTHORIZE_URL = "/v1/iam/oauth/authorize"
ACCESS_TOKEN_URL = "/v1/iam/oauth/token"
USERINFO_URL = "/v1/iam/oauth/userinfo"
def setup():
# Hardening must apply even if social-login provisioning fails, so a broken
# provider config can never silently leave native signup open.
try:
_setup_social_login()
finally:
_harden_login()
def _setup_social_login():
client_id = os.environ.get("IAM_CLIENT_ID")
client_secret = os.environ.get("IAM_CLIENT_SECRET")
base_url = os.environ.get("IAM_BASE_URL", "https://hanzo.id")
@@ -27,16 +49,24 @@ def setup():
print("hanzo_sso: IAM_CLIENT_ID / IAM_CLIENT_SECRET not set; skipping")
return
# Frappe validates that a Custom provider has a Redirect URL. Derive it from
# the site's own URL so it is correct per-org (each org's SQLite site sets
# its own host_name): https://<site>/api/method/.../custom/hanzo
redirect_url = frappe.utils.get_url(
f"/api/method/frappe.integrations.oauth2_logins.custom/{PROVIDER}"
)
values = {
"social_login_provider": "Custom",
"provider_name": PROVIDER,
"enable_social_login": 1,
"base_url": base_url,
"redirect_url": redirect_url,
"client_id": client_id,
"client_secret": client_secret,
"authorize_url": "/v1/iam/login/oauth/authorize",
"access_token_url": "/v1/iam/login/oauth/access_token",
"api_endpoint": "/v1/iam/userinfo",
"authorize_url": AUTHORIZE_URL,
"access_token_url": ACCESS_TOKEN_URL,
"api_endpoint": USERINFO_URL,
"auth_url_data": json.dumps(
{"response_type": "code", "scope": "openid profile email"}
),
@@ -55,3 +85,26 @@ def setup():
frappe.db.commit()
print(f"hanzo_sso: {action} Social Login Key '{PROVIDER}' -> {base_url}")
def _harden_login():
"""Make IAM the ONE identity authority: no native signup, SSO-only login."""
changed = []
# Remove native "Sign Up" / "Create Account".
if _set_single_if_field("Website Settings", "disable_signup", 1):
changed.append("Website Settings.disable_signup=1")
# Force SSO: hide the username/password form (Frappe v15+). Guarded so it is
# a no-op (never an error) on versions without the field.
if _set_single_if_field("System Settings", "disable_user_pass_login", 1):
changed.append("System Settings.disable_user_pass_login=1")
frappe.db.commit()
print(f"hanzo_sso: hardened login -> {', '.join(changed) or 'no fields present'}")
def _set_single_if_field(doctype: str, fieldname: str, value) -> bool:
"""set_single_value only when the field actually exists on the DocType."""
meta = frappe.get_meta(doctype)
if not meta.has_field(fieldname):
return False
frappe.db.set_single_value(doctype, fieldname, value)
return True