mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
Rename / namespace: - Finish github.com/pocketbase/* → github.com/hanzoai/base (+ /dbx) sweep across all Go sources; restore the renamed root launcher (Base, base.go). - Drop the upstream-only surfaces ours doesn't ship (admin ui/, the password/OTP/oauth2/impersonate auth files, extensions) re-added by the upstream merge. - Decomplect the on-disk layout to one base_/ namespace: data/, migrations/, hooks/, types.d.ts are siblings under base_/ (default data dir base_/data). Drop the hz_ prefix everywhere; collapse migratecmd's identical JS/Go branches to one path. - Remove the accidentally-committed hz_data/ dev dir (db + iam.key) and gitignore the runtime namespace dirs. Deps / build: - kmsclient now imported from the light github.com/hanzoai/kms/sdk/go module (the root kms module zip exceeds Go's download limit); cloud/pkg/base Mount removed (lives in the fused daemon). - replicate v0.4.0 → v0.8.0; metric.Counter.Get() (no prometheus dto); network/metrics.go import fix; auth gitlab/github tidy. Tests / behavior: - Wire registerNotifyWatcherHooks; debounce notify files past the kqueue rescan window so cross-instance settings/collections reload fires. - migratecmd: exclude createdAt/updatedAt (not created/updated); regenerate Go+JS automigration goldens from canonical output. - Convert the two stale archive red-probes to assert the landed defenses (backlog cap bounds memory; forged-segment signature is rejected). IAM-native (Base = pure IAM client): - Rip the ~1k-line home-grown embedded IAM (iam_embedded*.go) and the iam-user CLI. Base no longer reinvents identity as _iam_users/_superusers rows. IAM_ENDPOINT (hanzo.id, or an in-process iam.Embed in the fused daemon) is required; Base validates IAM JWTs via JWKS only — superuser is the IAM admin claim, resolved to an ephemeral unpersisted record. - Extract the IDV reverse-proxy (/v1/idv/*) into idv_proxy.go — it was mis-colocated in the embedded onboarding file and is independent of IAM. - Bind the SQL console (/v1/sql) and test it through real IAM JWTs against a JWKS stub (no hardcoded record tokens, no persisted rows).
343 lines
11 KiB
Go
343 lines
11 KiB
Go
// Hanzo KMS bridge for the base/platform plugin.
|
|
//
|
|
// The base/platform plugin needs three KMS operations per org:
|
|
//
|
|
// - GetSecret(orgId, secretPath) — fetch a per-org credential
|
|
// - SetSecret(orgId, secretPath, value) — write a per-org credential
|
|
// - DeleteSecret(orgId, secretPath) — remove a per-org credential
|
|
//
|
|
// All three route to the canonical Hanzo KMS surface owned by
|
|
// `github.com/hanzoai/kms/sdk/go/kmsclient`, which itself picks between
|
|
// HTTP (IAM bearer) and ZAP-native (NodeID ACL) based on the endpoint
|
|
// scheme. For in-cluster deployments operators MUST point KMS_ENDPOINT
|
|
// at zap://kms.hanzo.svc.cluster.local:9999 — the HTTP path stays as
|
|
// the fallback for external callers and dev.
|
|
//
|
|
// The previous implementation here targeted the legacy Infisical
|
|
// surface (`/api/v3/secrets/raw/`, `/api/v1/auth/universal-auth/login`,
|
|
// `/api/v2/workspace/environments`). None of those routes exist on the
|
|
// canonical `kmsd` image; every call was a 404 against
|
|
// `kms.hanzo.svc.cluster.local`. This file is the replacement.
|
|
|
|
package platform
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/kms/sdk/go/kmsclient"
|
|
)
|
|
|
|
const secretCacheTTL = 1 * time.Minute
|
|
|
|
// SecretMetadata is the shape returned by /v1/kms/orgs/{org}/secrets
|
|
// when the canonical kmsd lists keys. Kept here so the platform_test
|
|
// file can reuse it without re-importing the entire kmsclient surface.
|
|
type SecretMetadata struct {
|
|
Path string `json:"path"`
|
|
Version int `json:"version"`
|
|
}
|
|
|
|
// secretCacheEntry holds one cached secret value with its expiry.
|
|
type secretCacheEntry struct {
|
|
value string
|
|
expires time.Time
|
|
}
|
|
|
|
// KMSClient is the base/platform-side facade over kmsclient.Client.
|
|
//
|
|
// One client per (baseURL, authToken) pair. The internal kmsclient is
|
|
// lazy-initialised on the first secret call so that test code can
|
|
// construct a KMSClient with an empty config without panicking.
|
|
//
|
|
// Auth model: the constructor takes a raw authToken for backward
|
|
// compatibility, but the canonical kmsclient uses IAM
|
|
// client_credentials. When authToken is non-empty we send it on every
|
|
// HTTP request as a Bearer header; when empty kmsclient mints a token
|
|
// from the configured IAM identity. ZAP endpoints ignore both — the
|
|
// peer NodeID is the principal.
|
|
type KMSClient struct {
|
|
baseURL string
|
|
authToken string
|
|
|
|
mu sync.RWMutex
|
|
cache map[string]*secretCacheEntry
|
|
|
|
// Lazy-initialised on first call. nil until then.
|
|
initOnce sync.Once
|
|
cli *kmsclient.Client
|
|
initErr error
|
|
}
|
|
|
|
// NewKMSClient creates a new KMS client. Empty baseURL means "KMS not
|
|
// configured" — every call returns a typed not-configured error. The
|
|
// constructor never dials; the underlying kmsclient is built on the
|
|
// first secret call.
|
|
//
|
|
// authToken is honoured only on the HTTP transport — when non-empty it
|
|
// is appended verbatim as the bearer on every request. Tests pass a
|
|
// known bearer that the test httptest server validates; production
|
|
// callers leave it empty and let kmsclient drive the IAM exchange.
|
|
func NewKMSClient(baseURL, authToken string) *KMSClient {
|
|
return &KMSClient{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
authToken: strings.TrimSpace(authToken),
|
|
cache: make(map[string]*secretCacheEntry),
|
|
}
|
|
}
|
|
|
|
// init builds the underlying kmsclient lazily, picking the transport
|
|
// from the configured baseURL scheme.
|
|
//
|
|
// For "" / "http://" / "https://" baseURLs we use the HTTP path. When
|
|
// authToken is set we feed kmsclient a noop IAM config and rewrite the
|
|
// bearer at request time via the http.Client RoundTripper hook below —
|
|
// that keeps the test surface compatible without reaching for a real
|
|
// IAM. For zap:// or zap+mdns:// we use the ZAP path.
|
|
func (c *KMSClient) init() error {
|
|
c.initOnce.Do(func() {
|
|
if c.baseURL == "" {
|
|
c.initErr = fmt.Errorf("kms: base URL not configured")
|
|
return
|
|
}
|
|
low := strings.ToLower(c.baseURL)
|
|
switch {
|
|
case strings.HasPrefix(low, "zap://") || strings.HasPrefix(low, "zap+mdns://"):
|
|
// ZAP transport. Identity is mnemonic-derived: the
|
|
// LUX_MNEMONIC env var (set by the kms-operator-managed
|
|
// Secret) feeds NewIdentity together with the service
|
|
// path. Same (mnemonic, path) tuple → same NodeID on
|
|
// every replica.
|
|
ident, err := kmsclient.IdentityFromEnv(servicePathFromEnv("hanzo/base"))
|
|
if err != nil {
|
|
c.initErr = fmt.Errorf("kms: zap identity: %w", err)
|
|
return
|
|
}
|
|
cfg := kmsclient.Config{
|
|
Endpoint: c.baseURL,
|
|
Org: "hanzo", // overridden per-call via orgId arg
|
|
Env: "prod",
|
|
Identity: ident,
|
|
TransportNodeID: nodeIDFromEnv("hanzo-base"),
|
|
}
|
|
cli, kerr := kmsclient.New(cfg)
|
|
c.cli, c.initErr = cli, kerr
|
|
default:
|
|
// HTTP transport. authToken non-empty → wrap the http.Client
|
|
// transport so every outbound request carries the same
|
|
// bearer; authToken empty → let kmsclient drive IAM
|
|
// client_credentials.
|
|
cfg := kmsclient.Config{
|
|
Endpoint: c.baseURL,
|
|
IAMEndpoint: iamEndpointFromEnv("https://hanzo.id"),
|
|
Org: "hanzo", // overridden per-call via orgId arg
|
|
Env: "prod",
|
|
}
|
|
if c.authToken != "" {
|
|
cfg.HTTPClient = staticBearerClient(c.authToken)
|
|
cfg.IAMEndpoint = "https://hanzo.id"
|
|
cfg.ClientID = "static"
|
|
cfg.ClientSecret = "static"
|
|
} else {
|
|
cfg.ClientID = clientIDFromEnv()
|
|
cfg.ClientSecret = clientSecretFromEnv()
|
|
if cfg.ClientID == "" || cfg.ClientSecret == "" {
|
|
c.initErr = fmt.Errorf("kms: IAM client_credentials not configured (set IAM_CLIENT_ID / IAM_CLIENT_SECRET)")
|
|
return
|
|
}
|
|
}
|
|
cli, err := kmsclient.New(cfg)
|
|
c.cli, c.initErr = cli, err
|
|
}
|
|
})
|
|
return c.initErr
|
|
}
|
|
|
|
// scope splits an "orgId" arg + a "secretPath" arg ("provider/key") into
|
|
// the canonical (path, name) tuple kmsclient expects. orgId is folded
|
|
// into the kmsclient by constructing a per-call Client; the path is the
|
|
// secretPath prefix and the name is the last segment.
|
|
func scopeFromArgs(secretPath string) (path, name string) {
|
|
idx := strings.LastIndex(secretPath, "/")
|
|
if idx < 0 {
|
|
return "", secretPath
|
|
}
|
|
return secretPath[:idx], secretPath[idx+1:]
|
|
}
|
|
|
|
// orgClient returns a kmsclient.Client bound to the requested orgId.
|
|
// kmsclient.Client.Org is immutable, so we build a fresh client per
|
|
// distinct org. The HTTP token cache and any ZAP connection are owned
|
|
// by the lazy-init root c.cli — we copy the transport state shallowly.
|
|
//
|
|
// This is sub-optimal but rare: the base/platform org service caches
|
|
// resolved credentials in its own credsCache and only hits KMS on cache
|
|
// miss, so a per-call kmsclient construction is acceptable. The
|
|
// alternative — embedding orgId in every kmsclient call — would
|
|
// require widening the kmsclient surface, which we explicitly do not
|
|
// want.
|
|
func (c *KMSClient) orgClient(orgId string) (*kmsclient.Client, error) {
|
|
if err := c.init(); err != nil {
|
|
return nil, err
|
|
}
|
|
// kmsclient.Client doesn't expose its config; we rebuild with the
|
|
// same transport choice and the target orgId.
|
|
low := strings.ToLower(c.baseURL)
|
|
if strings.HasPrefix(low, "zap://") || strings.HasPrefix(low, "zap+mdns://") {
|
|
ident, err := kmsclient.IdentityFromEnv(servicePathFromEnv("hanzo/base"))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kms: zap identity: %w", err)
|
|
}
|
|
return kmsclient.New(kmsclient.Config{
|
|
Endpoint: c.baseURL,
|
|
Org: orgId,
|
|
Env: "prod",
|
|
Identity: ident,
|
|
TransportNodeID: nodeIDFromEnv("hanzo-base"),
|
|
})
|
|
}
|
|
cfg := kmsclient.Config{
|
|
Endpoint: c.baseURL,
|
|
IAMEndpoint: iamEndpointFromEnv("https://hanzo.id"),
|
|
Org: orgId,
|
|
Env: "prod",
|
|
}
|
|
if c.authToken != "" {
|
|
cfg.HTTPClient = staticBearerClient(c.authToken)
|
|
cfg.ClientID = "static"
|
|
cfg.ClientSecret = "static"
|
|
} else {
|
|
cfg.ClientID = clientIDFromEnv()
|
|
cfg.ClientSecret = clientSecretFromEnv()
|
|
}
|
|
return kmsclient.New(cfg)
|
|
}
|
|
|
|
// GetSecret fetches a secret with caching (1 min TTL).
|
|
func (c *KMSClient) GetSecret(orgId, secretPath string) (string, error) {
|
|
if err := c.checkConfig(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
cacheKey := orgId + "/" + secretPath
|
|
|
|
c.mu.RLock()
|
|
entry, ok := c.cache[cacheKey]
|
|
c.mu.RUnlock()
|
|
if ok && time.Now().Before(entry.expires) {
|
|
return entry.value, nil
|
|
}
|
|
|
|
cli, err := c.orgClient(orgId)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer cli.Close()
|
|
|
|
path, name := scopeFromArgs(secretPath)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
val, err := cli.Get(ctx, path, name)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
c.cache[cacheKey] = &secretCacheEntry{
|
|
value: val,
|
|
expires: time.Now().Add(secretCacheTTL),
|
|
}
|
|
if len(c.cache) > 5000 {
|
|
c.evictExpiredLocked()
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
return val, nil
|
|
}
|
|
|
|
// SetSecret creates or updates a secret.
|
|
func (c *KMSClient) SetSecret(orgId, secretPath, value string) error {
|
|
if err := c.checkConfig(); err != nil {
|
|
return err
|
|
}
|
|
cli, err := c.orgClient(orgId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cli.Close()
|
|
path, name := scopeFromArgs(secretPath)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := cli.Put(ctx, path, name, value); err != nil {
|
|
return err
|
|
}
|
|
cacheKey := orgId + "/" + secretPath
|
|
c.mu.Lock()
|
|
delete(c.cache, cacheKey)
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// DeleteSecret removes a secret.
|
|
func (c *KMSClient) DeleteSecret(orgId, secretPath string) error {
|
|
if err := c.checkConfig(); err != nil {
|
|
return err
|
|
}
|
|
cli, err := c.orgClient(orgId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cli.Close()
|
|
path, name := scopeFromArgs(secretPath)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
if err := cli.Delete(ctx, path, name); err != nil {
|
|
return err
|
|
}
|
|
cacheKey := orgId + "/" + secretPath
|
|
c.mu.Lock()
|
|
delete(c.cache, cacheKey)
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// InvalidateCache clears all cached secrets for an org.
|
|
func (c *KMSClient) InvalidateCache(orgId string) {
|
|
prefix := orgId + "/"
|
|
c.mu.Lock()
|
|
for k := range c.cache {
|
|
if strings.HasPrefix(k, prefix) {
|
|
delete(c.cache, k)
|
|
}
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *KMSClient) checkConfig() error {
|
|
if c.baseURL == "" {
|
|
return fmt.Errorf("kms: base URL not configured")
|
|
}
|
|
if c.authToken == "" {
|
|
// Empty authToken is only fatal if we also lack IAM credentials.
|
|
// We can't tell at checkConfig() time (no init yet), so we
|
|
// preserve the historical contract here: empty authToken on an
|
|
// empty IAM env returns the same error the tests assert.
|
|
if clientIDFromEnv() == "" || clientSecretFromEnv() == "" {
|
|
return fmt.Errorf("kms: auth token not configured")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *KMSClient) evictExpiredLocked() {
|
|
now := time.Now()
|
|
for k, v := range c.cache {
|
|
if now.After(v.expires) {
|
|
delete(c.cache, k)
|
|
}
|
|
}
|
|
}
|