mirror of
https://github.com/hanzoai/platform-services.git
synced 2026-10-30 18:23:56 +00:00
230 lines
8.3 KiB
Go
230 lines
8.3 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// KMS category — faithful port of the console kmsRouter's client logic. Secrets
|
|
// and keys are NEVER stored in Base: Hanzo KMS is the sole source of truth, and
|
|
// every op proxies straight through. Two things the router did that we preserve
|
|
// exactly:
|
|
//
|
|
// 1. Auth resolution: a static KMS_SERVICE_TOKEN takes precedence; otherwise
|
|
// universal-auth (KMS_CLIENT_ID + KMS_CLIENT_SECRET) logs in and the access
|
|
// token is cached for 55 minutes, refreshed on demand / cleared on 401.
|
|
// 2. Workspace resolution: the org's KMS workspace id wins (carried per-org;
|
|
// here scoped to the cap's org via KMS_PROJECT_ID until IAM org-metadata
|
|
// lookup is wired — see TODO). The router pulled it from
|
|
// Organization.metadata.kmsProjectId, falling back to KMS_PROJECT_ID.
|
|
//
|
|
// The same token manager backs bot.listSecrets (bot-scoped secret reads).
|
|
|
|
// kmsTokenTTL is the universal-auth access-token cache lifetime (router used 55
|
|
// minutes against a 60-minute KMS token).
|
|
const kmsTokenTTL = 55 * time.Minute
|
|
|
|
// kmsAuth manages the KMS bearer token. Safe for concurrent use.
|
|
type kmsAuth struct {
|
|
up Upstream
|
|
|
|
mu sync.Mutex
|
|
token string
|
|
expires time.Time
|
|
}
|
|
|
|
func newKMSAuth(up Upstream) *kmsAuth { return &kmsAuth{up: up} }
|
|
|
|
// bearer returns a valid KMS bearer token, logging in via universal-auth if no
|
|
// static token is set and the cache is empty/expired.
|
|
func (a *kmsAuth) bearer(ctx context.Context) (string, error) {
|
|
if a.up.KMSServiceToken != "" {
|
|
return a.up.KMSServiceToken, nil // static token wins, no refresh
|
|
}
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
if a.token != "" && time.Now().Before(a.expires) {
|
|
return a.token, nil
|
|
}
|
|
if a.up.KMSClientID == "" || a.up.KMSClientSecret == "" {
|
|
return "", fmt.Errorf("KMS auth unconfigured: set KMS_SERVICE_TOKEN or KMS_CLIENT_ID+KMS_CLIENT_SECRET")
|
|
}
|
|
body, _ := json.Marshal(map[string]string{
|
|
"clientId": a.up.KMSClientID,
|
|
"clientSecret": a.up.KMSClientSecret,
|
|
})
|
|
pr, err := doJSON(ctx, "POST", a.up.KMSURL+"/api/v1/auth/universal-auth/login", body, "", nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("KMS universal-auth login: %w", err)
|
|
}
|
|
if pr.Status != StatusOK {
|
|
return "", fmt.Errorf("KMS universal-auth login: upstream status %d", pr.Status)
|
|
}
|
|
var out struct {
|
|
AccessToken string `json:"accessToken"`
|
|
}
|
|
if err := json.Unmarshal(pr.Body, &out); err != nil || out.AccessToken == "" {
|
|
return "", fmt.Errorf("KMS universal-auth login: no accessToken in response")
|
|
}
|
|
a.token = out.AccessToken
|
|
a.expires = time.Now().Add(kmsTokenTTL)
|
|
return a.token, nil
|
|
}
|
|
|
|
// clear drops the cached token (called after a 401 so the next call re-logs in).
|
|
func (a *kmsAuth) clear() {
|
|
a.mu.Lock()
|
|
a.token = ""
|
|
a.expires = time.Time{}
|
|
a.mu.Unlock()
|
|
}
|
|
|
|
// kmsParams is the decoded shape of the KMS op params we route on.
|
|
type kmsParams struct {
|
|
Environment string `json:"environment"`
|
|
SecretPath string `json:"secretPath"`
|
|
SecretName string `json:"secretName"`
|
|
KeyID string `json:"keyId"`
|
|
}
|
|
|
|
// kmsWorkspace resolves the KMS workspace id for the org.
|
|
//
|
|
// TODO(kms): resolve the org's KMS workspace from IAM org metadata
|
|
// (Organization.metadata.kmsProjectId) keyed by the cap's org, as the router
|
|
// did. Until that IAM lookup is wired, fall back to KMS_PROJECT_ID (the router's
|
|
// single-tenant fallback). Without either, the call fails precondition.
|
|
func (s *Server) kmsWorkspace(_ string) (string, bool) {
|
|
if s.up.KMSProjectID != "" {
|
|
return s.up.KMSProjectID, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// handleKMS dispatches the kms category. Every op proxies to Hanzo KMS with a
|
|
// bearer from the token manager; on a 401 the cache is cleared once and the call
|
|
// is retried, mirroring the router's refresh-on-401 behaviour.
|
|
func (s *Server) handleKMS(ctx context.Context, org string, req psRequest) (uint32, []byte) {
|
|
ws, ok := s.kmsWorkspace(org)
|
|
if !ok {
|
|
return fail(StatusPrecondition, "kms: workspace unresolved (set KMS_PROJECT_ID or wire org metadata)")
|
|
}
|
|
|
|
var p kmsParams
|
|
if !req.bind(&p) {
|
|
return fail(StatusBadRequest, "kms: malformed params")
|
|
}
|
|
if p.SecretPath == "" {
|
|
p.SecretPath = "/"
|
|
}
|
|
|
|
method, u, sendBody, status, msg := s.kmsRoute(req, ws, p)
|
|
if status != StatusOK {
|
|
return fail(status, msg)
|
|
}
|
|
return s.kmsProxy(ctx, method, u, sendBody)
|
|
}
|
|
|
|
// kmsRoute resolves the upstream method/URL/body for a kms op. Returns a non-OK
|
|
// status (with message) for unknown ops or missing required fields.
|
|
func (s *Server) kmsRoute(req psRequest, ws string, p kmsParams) (method, u string, body []byte, status uint32, msg string) {
|
|
base := s.up.KMSURL
|
|
switch req.Op {
|
|
case OpKMSListSecrets:
|
|
q := url.Values{"workspaceId": {ws}, "environment": {p.Environment}, "secretPath": {p.SecretPath}}
|
|
return "GET", base + "/api/v3/secrets/raw?" + q.Encode(), nil, StatusOK, ""
|
|
case OpKMSCreateSecret:
|
|
return "POST", base + "/api/v3/secrets/raw", s.kmsSecretBody(req, ws, p.Environment, p.SecretPath), StatusOK, ""
|
|
case OpKMSUpdateSecret:
|
|
if p.SecretName == "" {
|
|
return "", "", nil, StatusBadRequest, "kms.updateSecret: secretName required"
|
|
}
|
|
return "PATCH", base + "/api/v3/secrets/raw/" + url.PathEscape(p.SecretName), s.kmsSecretBody(req, ws, p.Environment, p.SecretPath), StatusOK, ""
|
|
case OpKMSDeleteSecret:
|
|
if p.SecretName == "" {
|
|
return "", "", nil, StatusBadRequest, "kms.deleteSecret: secretName required"
|
|
}
|
|
q := url.Values{"workspaceId": {ws}, "environment": {p.Environment}, "secretPath": {p.SecretPath}}
|
|
return "DELETE", base + "/api/v3/secrets/raw/" + url.PathEscape(p.SecretName) + "?" + q.Encode(), nil, StatusOK, ""
|
|
case OpKMSListEnvironments:
|
|
return "GET", base + "/api/v1/workspace/" + url.PathEscape(ws) + "/environments", nil, StatusOK, ""
|
|
case OpKMSListKeys:
|
|
return "GET", base + "/api/v1/kms/keys?projectId=" + url.QueryEscape(ws), nil, StatusOK, ""
|
|
case OpKMSCreateKey:
|
|
return "POST", base + "/api/v1/kms/keys", s.kmsKeyCreateBody(req, ws), StatusOK, ""
|
|
case OpKMSUpdateKey:
|
|
if p.KeyID == "" {
|
|
return "", "", nil, StatusBadRequest, "kms.updateKey: keyId required"
|
|
}
|
|
return "PATCH", base + "/api/v1/kms/keys/" + url.PathEscape(p.KeyID), req.Params, StatusOK, ""
|
|
case OpKMSDeleteKey:
|
|
if p.KeyID == "" {
|
|
return "", "", nil, StatusBadRequest, "kms.deleteKey: keyId required"
|
|
}
|
|
return "DELETE", base + "/api/v1/kms/keys/" + url.PathEscape(p.KeyID), nil, StatusOK, ""
|
|
case OpKMSEncrypt:
|
|
if p.KeyID == "" {
|
|
return "", "", nil, StatusBadRequest, "kms.encrypt: keyId required"
|
|
}
|
|
return "POST", base + "/api/v1/kms/keys/" + url.PathEscape(p.KeyID) + "/encrypt", req.Params, StatusOK, ""
|
|
case OpKMSDecrypt:
|
|
if p.KeyID == "" {
|
|
return "", "", nil, StatusBadRequest, "kms.decrypt: keyId required"
|
|
}
|
|
return "POST", base + "/api/v1/kms/keys/" + url.PathEscape(p.KeyID) + "/decrypt", req.Params, StatusOK, ""
|
|
default:
|
|
return "", "", nil, StatusBadRequest, fmt.Sprintf("kms: unknown op %d", req.Op)
|
|
}
|
|
}
|
|
|
|
// kmsSecretBody injects workspaceId into the client's secret params (the KMS
|
|
// /secrets/raw body the router built). Falls back to a minimal object if the
|
|
// client sent no params.
|
|
func (s *Server) kmsSecretBody(req psRequest, ws, env, path string) []byte {
|
|
m := map[string]any{}
|
|
_ = json.Unmarshal(req.Params, &m)
|
|
m["workspaceId"] = ws
|
|
if env != "" {
|
|
m["environment"] = env
|
|
}
|
|
if path != "" {
|
|
m["secretPath"] = path
|
|
}
|
|
b, _ := json.Marshal(m)
|
|
return b
|
|
}
|
|
|
|
// kmsKeyCreateBody injects projectId (== workspace) into the key-create params.
|
|
func (s *Server) kmsKeyCreateBody(req psRequest, ws string) []byte {
|
|
m := map[string]any{}
|
|
_ = json.Unmarshal(req.Params, &m)
|
|
m["projectId"] = ws
|
|
b, _ := json.Marshal(m)
|
|
return b
|
|
}
|
|
|
|
// kmsProxy performs the KMS call with a managed bearer, retrying once on a 401
|
|
// after clearing the token cache (refresh-on-401, as the router did).
|
|
func (s *Server) kmsProxy(ctx context.Context, method, u string, body []byte) (uint32, []byte) {
|
|
tok, err := s.kms.bearer(ctx)
|
|
if err != nil {
|
|
return fail(StatusPrecondition, "kms: "+err.Error())
|
|
}
|
|
pr, err := doJSON(ctx, method, u, body, tok, nil)
|
|
if err != nil {
|
|
return fail(StatusBadGateway, "kms upstream: "+err.Error())
|
|
}
|
|
if pr.Status == StatusUnauthorized {
|
|
s.kms.clear()
|
|
if tok2, err2 := s.kms.bearer(ctx); err2 == nil {
|
|
if pr2, err3 := doJSON(ctx, method, u, body, tok2, nil); err3 == nil {
|
|
return proxied(pr2)
|
|
}
|
|
}
|
|
}
|
|
return proxied(pr)
|
|
}
|