mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
203 lines
6.9 KiB
Go
203 lines
6.9 KiB
Go
package platform
|
|
|
|
// Onboarding for the embedded IAM.
|
|
//
|
|
// Decomplected from IDV: the embedded IAM owns the onboarding STATE
|
|
// (which applications exist, which step each is on, where the
|
|
// attestation digest lives) and proxies the biometric step to a
|
|
// separately-deployed `hanzoai/idv` service over HTTP. IAM never
|
|
// imports a provider SDK; it knows nothing about Jumio/Onfido/Plaid/
|
|
// //Persona/Intellicheck/etc. — that knowledge
|
|
// belongs entirely to the IDV service.
|
|
//
|
|
// Stacked services
|
|
// ================
|
|
//
|
|
// IAM (`hanzoai/base`) → /v1/iam/* identity, sessions
|
|
// IDV (`hanzoai/idv`) → /v1/idv/* identity verification
|
|
//
|
|
// Each runs as its own binary, each configured independently. The
|
|
// only link is one env on IAM:
|
|
//
|
|
// IDV_ENDPOINT=https://idv.hanzo.ai
|
|
//
|
|
// Unset (or empty) → IDV is disabled; the biometric step auto-
|
|
// approves (dev only). For regulated production deployments, set
|
|
// IDV_ENDPOINT to your IDV service and configure providers there.
|
|
//
|
|
// Provider selection lives in the IDV service, not here:
|
|
//
|
|
// # On the IDV service (hanzoai/idv binary), not on IAM:
|
|
// IDV_PROVIDER=jumio | onfido | plaid | lexisnexis | intellicheck |
|
|
// idmerit | berbix | | | persona
|
|
// <PROVIDER>_API_TOKEN=… (or per-provider key var)
|
|
// <PROVIDER>_BASE_URL=… (optional region override)
|
|
//
|
|
// State machine (IAM-owned)
|
|
// =========================
|
|
//
|
|
// unverified → identity_pending → documents_pending →
|
|
// biometric_pending → screening → approved | rejected
|
|
//
|
|
// Endpoints (IAM-owned)
|
|
// =====================
|
|
//
|
|
// POST /v1/iam/onboarding step 1 — create application
|
|
// POST /v1/iam/onboarding/{id}/identity step 2 — PII (DOB, address)
|
|
// POST /v1/iam/onboarding/{id}/documents step 3 — multipart upload
|
|
// POST /v1/iam/onboarding/{id}/biometric step 4 — proxy to IDV service
|
|
// POST /v1/iam/onboarding/{id}/screen step 5a — AML/sanctions
|
|
// POST /v1/iam/onboarding/{id}/submit step 5b — fan-out
|
|
// GET /v1/iam/onboarding/{id} status
|
|
//
|
|
// Discovery (sibling mount — NOT under /v1/iam)
|
|
// =============================================
|
|
//
|
|
// GET /v1/idv/status
|
|
// POST /v1/idv/sessions
|
|
// GET /v1/idv/sessions/{id}
|
|
// POST /v1/idv/webhook/{provider}
|
|
//
|
|
// IDV lives at /v1/idv/* — its own service domain, NOT a child of
|
|
// /v1/iam. base transparently proxies the whole /v1/idv/* surface
|
|
// to ${IDV_ENDPOINT}/v1/idv/* so SPAs talk to one host but the
|
|
// upstream is decomplected. With IDV_ENDPOINT unset, the proxy
|
|
// returns 200 {enabled:false} on /v1/idv/status and 503 on
|
|
// everything else.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/router"
|
|
)
|
|
|
|
// IDVEndpoint returns the configured upstream IDV service URL, or
|
|
// "" when IDV is disabled. The trailing slash is stripped so
|
|
// callers can always concatenate "/v1/idv/...".
|
|
func IDVEndpoint() string {
|
|
return strings.TrimRight(os.Getenv("IDV_ENDPOINT"), "/")
|
|
}
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Discovery + transparent proxy: /v1/idv/*
|
|
// --------------------------------------------------------------------------
|
|
|
|
// idvMount is the mount path for the IDV proxy. Sibling of /v1/iam,
|
|
// NOT a child — IDV is its own service domain.
|
|
const idvMount = "/v1/idv"
|
|
|
|
type idvStatusResp struct {
|
|
Enabled bool `json:"enabled"`
|
|
Endpoint string `json:"endpoint"`
|
|
Provider string `json:"provider"`
|
|
Label string `json:"label"`
|
|
}
|
|
|
|
// registerIDVProxy mounts /v1/idv/* — sibling of /v1/iam, unconditional
|
|
// (does NOT depend on embedded IAM mode). With IDV_ENDPOINT unset,
|
|
// /v1/idv/status returns {enabled:false} and the rest return 503.
|
|
//
|
|
// Renamed from registerEmbeddedIDVRoutes — the name was misleading
|
|
// because IDV is independent of the IAM mode toggle. base proxies
|
|
// to whatever's at IDV_ENDPOINT regardless of where IAM lives.
|
|
func (p *plugin) registerIDVProxy(r *router.Router[*core.RequestEvent]) {
|
|
r.GET(idvMount+"/status", p.handleIDVStatus)
|
|
r.GET(idvMount+"/{path...}", p.handleIDVProxy)
|
|
r.POST(idvMount+"/{path...}", p.handleIDVProxy)
|
|
r.PUT(idvMount+"/{path...}", p.handleIDVProxy)
|
|
r.PATCH(idvMount+"/{path...}", p.handleIDVProxy)
|
|
r.DELETE(idvMount+"/{path...}", p.handleIDVProxy)
|
|
}
|
|
|
|
func (p *plugin) handleIDVStatus(e *core.RequestEvent) error {
|
|
endpoint := IDVEndpoint()
|
|
if endpoint == "" {
|
|
return e.JSON(http.StatusOK, idvStatusResp{Enabled: false})
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(e.Request.Context(), 4*time.Second)
|
|
defer cancel()
|
|
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", endpoint+"/v1/idv/status", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
// IDV unreachable — surface "enabled but degraded" rather
|
|
// than masking. SPAs can show a friendly error.
|
|
return e.JSON(http.StatusOK, idvStatusResp{
|
|
Enabled: true,
|
|
Endpoint: endpoint,
|
|
Label: "unreachable",
|
|
})
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
|
|
var upstream idvStatusResp
|
|
if err := json.Unmarshal(body, &upstream); err != nil {
|
|
return e.JSON(http.StatusOK, idvStatusResp{
|
|
Enabled: true,
|
|
Endpoint: endpoint,
|
|
Label: "unknown",
|
|
})
|
|
}
|
|
upstream.Enabled = true
|
|
if upstream.Endpoint == "" {
|
|
upstream.Endpoint = endpoint
|
|
}
|
|
return e.JSON(http.StatusOK, upstream)
|
|
}
|
|
|
|
// handleIDVProxy is a thin reverse proxy for everything under
|
|
// /v1/idv/* (other than /status which is handled separately for the
|
|
// "disabled" short-circuit). One round-trip, no body buffering, no
|
|
// header rewriting beyond Host.
|
|
func (p *plugin) handleIDVProxy(e *core.RequestEvent) error {
|
|
endpoint := IDVEndpoint()
|
|
if endpoint == "" {
|
|
return e.Error(http.StatusServiceUnavailable, "IDV not configured (set IDV_ENDPOINT)", nil)
|
|
}
|
|
|
|
upstreamPath := strings.TrimPrefix(e.Request.URL.Path, "")
|
|
if upstreamPath == "" {
|
|
upstreamPath = "/v1/idv/"
|
|
}
|
|
upstreamURL := endpoint + upstreamPath
|
|
if e.Request.URL.RawQuery != "" {
|
|
upstreamURL += "?" + e.Request.URL.RawQuery
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(e.Request.Context(), e.Request.Method, upstreamURL, e.Request.Body)
|
|
if err != nil {
|
|
return e.Error(http.StatusInternalServerError, "proxy req: "+err.Error(), nil)
|
|
}
|
|
// Forward client headers verbatim. The IDV service handles its
|
|
// own auth (API key / signature on inbound webhooks).
|
|
for k, vv := range e.Request.Header {
|
|
for _, v := range vv {
|
|
req.Header.Add(k, v)
|
|
}
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return e.Error(http.StatusBadGateway, "upstream IDV unreachable: "+err.Error(), nil)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
for k, vv := range resp.Header {
|
|
for _, v := range vv {
|
|
e.Response.Header().Add(k, v)
|
|
}
|
|
}
|
|
e.Response.WriteHeader(resp.StatusCode)
|
|
_, _ = io.Copy(e.Response, resp.Body)
|
|
return nil
|
|
}
|