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:
@@ -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
@@ -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 &
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user