1089 lines
35 KiB
Go
1089 lines
35 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"account/internal/auth"
|
|
"account/internal/store"
|
|
)
|
|
|
|
type xworkmateAccessContext struct {
|
|
Tenant *store.Tenant
|
|
Domain string
|
|
MembershipRole string
|
|
ProfileScope string
|
|
CanEditIntegrations bool
|
|
CanManageTenant bool
|
|
}
|
|
|
|
type xworkmateProfilePayload struct {
|
|
OpenclawURL string `json:"openclawUrl"`
|
|
OpenclawOrigin string `json:"openclawOrigin"`
|
|
VaultURL string `json:"vaultUrl"`
|
|
VaultNamespace string `json:"vaultNamespace"`
|
|
VaultSecretPath string `json:"vaultSecretPath"`
|
|
VaultSecretKey string `json:"vaultSecretKey"`
|
|
SecretLocators []xworkmateSecretLocatorPayload `json:"secretLocators"`
|
|
ApisixURL string `json:"apisixUrl"`
|
|
BridgeServerURL string `json:"bridgeServerUrl"`
|
|
AcpBridgeServerProfiles []xworkmateAcpBridgeServerProfilePayload `json:"acpBridgeServerProfiles"`
|
|
}
|
|
|
|
type xworkmateSecretLocatorPayload struct {
|
|
ID string `json:"id"`
|
|
Provider string `json:"provider"`
|
|
SecretPath string `json:"secretPath"`
|
|
SecretKey string `json:"secretKey"`
|
|
Target string `json:"target"`
|
|
Required bool `json:"required"`
|
|
}
|
|
|
|
type xworkmateAcpBridgeServerProfilePayload struct {
|
|
ProviderKey string `json:"providerKey"`
|
|
Label string `json:"label"`
|
|
Badge string `json:"badge"`
|
|
Endpoint string `json:"endpoint"`
|
|
AuthRef string `json:"authRef"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
var xworkmateForbiddenTokenFields = map[string]struct{}{
|
|
"openclawtoken": {},
|
|
"gatewaytoken": {},
|
|
"vaulttoken": {},
|
|
"apisixtoken": {},
|
|
}
|
|
|
|
func (h *handler) ensureSharedXWorkmateTenant(ctx context.Context) error {
|
|
tenant := &store.Tenant{
|
|
ID: store.SharedXWorkmateTenantID,
|
|
Name: store.SharedXWorkmateTenantName,
|
|
Edition: store.SharedPublicTenantEdition,
|
|
}
|
|
if err := h.store.EnsureTenant(ctx, tenant); err != nil {
|
|
return err
|
|
}
|
|
|
|
return h.store.EnsureTenantDomain(ctx, &store.TenantDomain{
|
|
TenantID: tenant.ID,
|
|
Domain: store.SharedXWorkmateDomain,
|
|
Kind: store.TenantDomainKindGenerated,
|
|
IsPrimary: true,
|
|
Status: store.TenantDomainStatusVerified,
|
|
})
|
|
}
|
|
|
|
func (h *handler) resolveTenantHost(c *gin.Context) string {
|
|
for _, headerName := range []string{"X-Forwarded-Host", "X-Original-Host", "X-Host"} {
|
|
if candidate := store.NormalizeHostname(c.GetHeader(headerName)); candidate != "" {
|
|
return candidate
|
|
}
|
|
}
|
|
if candidate := store.NormalizeHostname(c.Request.Host); candidate != "" {
|
|
return candidate
|
|
}
|
|
return store.SharedXWorkmateDomain
|
|
}
|
|
|
|
func (h *handler) resolveFrontendURL(c *gin.Context) string {
|
|
candidates := []string{
|
|
strings.TrimSpace(c.Query("frontend_url")),
|
|
strings.TrimSpace(c.GetHeader("X-Frontend-Url")),
|
|
strings.TrimSpace(c.GetHeader("Origin")),
|
|
}
|
|
if referer := strings.TrimSpace(c.GetHeader("Referer")); referer != "" {
|
|
if parsed, err := url.Parse(referer); err == nil && parsed.Host != "" {
|
|
candidates = append(candidates, fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host))
|
|
}
|
|
}
|
|
candidates = append(candidates, strings.TrimSpace(h.oauthFrontendURL))
|
|
|
|
for _, candidate := range candidates {
|
|
if validated := h.validateFrontendURL(candidate); validated != "" {
|
|
return validated
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *handler) validateFrontendURL(raw string) string {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil || parsed.Host == "" {
|
|
return ""
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return ""
|
|
}
|
|
|
|
host := store.NormalizeHostname(parsed.Host)
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
if store.IsSharedTenantHost(host) {
|
|
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
|
}
|
|
if _, _, err := h.store.ResolveTenantByHost(context.Background(), host); err == nil {
|
|
return fmt.Sprintf("%s://%s", parsed.Scheme, parsed.Host)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildOAuthState(frontendURL string) string {
|
|
nonce := generateRandomState()
|
|
trimmed := strings.TrimSpace(frontendURL)
|
|
if trimmed == "" {
|
|
return nonce
|
|
}
|
|
encoded := base64.RawURLEncoding.EncodeToString([]byte(trimmed))
|
|
return nonce + "." + encoded
|
|
}
|
|
|
|
func parseOAuthStateFrontendURL(state string) string {
|
|
parts := strings.SplitN(strings.TrimSpace(state), ".", 2)
|
|
if len(parts) != 2 {
|
|
return ""
|
|
}
|
|
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(decoded))
|
|
}
|
|
|
|
func generateRandomState() string {
|
|
return (&handler{}).generateState()
|
|
}
|
|
|
|
func (h *handler) currentAuthenticatedUser(c *gin.Context) (*store.User, bool) {
|
|
userID := strings.TrimSpace(auth.GetUserID(c))
|
|
if userID == "" || userID == "system" {
|
|
respondError(c, http.StatusUnauthorized, "session_token_required", "session token is required")
|
|
return nil, false
|
|
}
|
|
|
|
user, err := h.store.GetUserByID(c.Request.Context(), userID)
|
|
if err != nil {
|
|
respondError(c, http.StatusUnauthorized, "session_user_lookup_failed", "failed to load session user")
|
|
return nil, false
|
|
}
|
|
if !user.Active {
|
|
respondError(c, http.StatusForbidden, "account_suspended", "your account has been suspended")
|
|
return nil, false
|
|
}
|
|
return user, true
|
|
}
|
|
|
|
func (h *handler) ensureSharedTenantMembership(ctx context.Context, user *store.User) (string, error) {
|
|
role := store.TenantMembershipRoleUser
|
|
if h.isRootAccount(user) ||
|
|
strings.EqualFold(strings.TrimSpace(user.Role), store.RoleAdmin) ||
|
|
strings.EqualFold(strings.TrimSpace(user.Role), store.RoleOperator) {
|
|
role = store.TenantMembershipRoleAdmin
|
|
}
|
|
return role, h.store.UpsertTenantMembership(ctx, &store.TenantMembership{
|
|
TenantID: store.SharedXWorkmateTenantID,
|
|
UserID: user.ID,
|
|
Role: role,
|
|
})
|
|
}
|
|
|
|
func (h *handler) resolveXWorkmateAccess(ctx context.Context, host string, user *store.User) (*xworkmateAccessContext, error) {
|
|
normalizedHost := store.NormalizeHostname(host)
|
|
if store.IsSharedTenantHost(normalizedHost) {
|
|
if err := h.ensureSharedXWorkmateTenant(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
tenant, domain, err := h.store.ResolveTenantByHost(ctx, normalizedHost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
access := &xworkmateAccessContext{
|
|
Tenant: tenant,
|
|
Domain: store.SharedXWorkmateDomain,
|
|
}
|
|
if domain != nil && strings.TrimSpace(domain.Domain) != "" {
|
|
access.Domain = domain.Domain
|
|
}
|
|
|
|
if tenant.Edition == store.SharedPublicTenantEdition {
|
|
role, err := h.ensureSharedTenantMembership(ctx, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
access.MembershipRole = role
|
|
access.ProfileScope = store.XWorkmateProfileScopeTenantShared
|
|
access.CanEditIntegrations = role == store.TenantMembershipRoleAdmin
|
|
access.CanManageTenant = access.CanEditIntegrations
|
|
return access, nil
|
|
}
|
|
|
|
membership, err := h.store.GetTenantMembership(ctx, tenant.ID, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
access.MembershipRole = membership.Role
|
|
access.ProfileScope = store.XWorkmateProfileScopeUserPrivate
|
|
access.CanEditIntegrations = true
|
|
access.CanManageTenant = membership.Role == store.TenantMembershipRoleAdmin
|
|
return access, nil
|
|
}
|
|
|
|
func buildSessionTenantEntries(memberships []store.TenantMembership) []gin.H {
|
|
if len(memberships) == 0 {
|
|
return []gin.H{}
|
|
}
|
|
|
|
result := make([]gin.H, 0, len(memberships))
|
|
for _, membership := range memberships {
|
|
entry := gin.H{
|
|
"id": membership.TenantID,
|
|
"role": membership.Role,
|
|
}
|
|
if strings.TrimSpace(membership.TenantName) != "" {
|
|
entry["name"] = membership.TenantName
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func buildXWorkmateTokenConfigured(profile *store.XWorkmateProfile) gin.H {
|
|
result := gin.H{
|
|
"openclaw": false,
|
|
"vault": false,
|
|
"apisix": false,
|
|
}
|
|
if profile == nil {
|
|
return result
|
|
}
|
|
|
|
if hasOpenclawXWorkmateSecretLocator(profile) {
|
|
result["openclaw"] = true
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func buildXWorkmateTokenConfiguredWithVaultStatus(profile *store.XWorkmateProfile, vaultStatus map[string]bool) gin.H {
|
|
result := buildXWorkmateTokenConfigured(profile)
|
|
if len(vaultStatus) == 0 {
|
|
return result
|
|
}
|
|
|
|
if configured, ok := vaultStatus[store.XWorkmateSecretLocatorTargetOpenclawGatewayToken]; ok {
|
|
result["openclaw"] = configured
|
|
}
|
|
if configured, ok := vaultStatus[store.XWorkmateSecretLocatorTargetVaultRootToken]; ok {
|
|
result["vault"] = configured
|
|
}
|
|
if configured, ok := vaultStatus[store.XWorkmateSecretLocatorTargetAIGatewayAccessToken]; ok {
|
|
result["apisix"] = configured
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func hasOpenclawXWorkmateSecretLocator(profile *store.XWorkmateProfile) bool {
|
|
if profile == nil {
|
|
return false
|
|
}
|
|
|
|
if strings.TrimSpace(profile.VaultSecretPath) != "" && strings.TrimSpace(profile.VaultSecretKey) != "" {
|
|
return true
|
|
}
|
|
for _, locator := range profile.SecretLocators {
|
|
if locator.Target != store.XWorkmateSecretLocatorTargetOpenclawGatewayToken {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(locator.SecretPath) != "" && strings.TrimSpace(locator.SecretKey) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func buildXWorkmateSecretLocators(profile *store.XWorkmateProfile) []gin.H {
|
|
if profile == nil || len(profile.SecretLocators) == 0 {
|
|
return []gin.H{}
|
|
}
|
|
|
|
result := make([]gin.H, 0, len(profile.SecretLocators))
|
|
for _, locator := range profile.SecretLocators {
|
|
entry := gin.H{
|
|
"id": locator.ID,
|
|
"provider": locator.Provider,
|
|
"secretPath": locator.SecretPath,
|
|
"secretKey": locator.SecretKey,
|
|
"target": locator.Target,
|
|
"required": locator.Required,
|
|
}
|
|
result = append(result, entry)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func buildStoreXWorkmateSecretLocators(locators []xworkmateSecretLocatorPayload) []store.XWorkmateSecretLocator {
|
|
if len(locators) == 0 {
|
|
return []store.XWorkmateSecretLocator{}
|
|
}
|
|
|
|
result := make([]store.XWorkmateSecretLocator, 0, len(locators))
|
|
for _, locator := range locators {
|
|
result = append(result, store.XWorkmateSecretLocator{
|
|
ID: locator.ID,
|
|
Provider: locator.Provider,
|
|
SecretPath: locator.SecretPath,
|
|
SecretKey: locator.SecretKey,
|
|
Target: locator.Target,
|
|
Required: locator.Required,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (h *handler) buildSessionUser(ctx context.Context, host string, user *store.User) (gin.H, error) {
|
|
access, err := h.resolveXWorkmateAccess(ctx, host, user)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
memberships, err := h.store.ListTenantMembershipsByUser(ctx, user.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
payload := sanitizeUser(user, nil)
|
|
payload["tenantId"] = access.Tenant.ID
|
|
payload["tenants"] = buildSessionTenantEntries(memberships)
|
|
return payload, nil
|
|
}
|
|
|
|
func envXWorkmateValue(key string) string {
|
|
return strings.TrimSpace(os.Getenv(key))
|
|
}
|
|
|
|
func buildSharedXWorkmateAcpBridgeServerProfiles() []gin.H {
|
|
profiles := make([]gin.H, 0, 2)
|
|
|
|
appendProfile := func(providerKey, label, badge, endpointEnv, authRefEnv string) {
|
|
endpoint := envXWorkmateValue(endpointEnv)
|
|
if endpoint == "" {
|
|
return
|
|
}
|
|
profiles = append(profiles, gin.H{
|
|
"providerKey": providerKey,
|
|
"label": label,
|
|
"badge": badge,
|
|
"endpoint": endpoint,
|
|
"authRef": envXWorkmateValue(authRefEnv),
|
|
"enabled": true,
|
|
})
|
|
}
|
|
|
|
appendProfile("codex", "Codex", "Codex", "XWORKMATE_ACP_CODEX_URL", "XWORKMATE_ACP_CODEX_AUTH_REF")
|
|
appendProfile("opencode", "OpenCode", "OpenCode", "XWORKMATE_ACP_OPENCODE_URL", "XWORKMATE_ACP_OPENCODE_AUTH_REF")
|
|
|
|
return profiles
|
|
}
|
|
|
|
func buildResolvedXWorkmateProfile(access *xworkmateAccessContext, profile *store.XWorkmateProfile) (*store.XWorkmateProfile, string, []gin.H) {
|
|
var resolved store.XWorkmateProfile
|
|
if profile != nil {
|
|
resolved = *profile
|
|
resolved.SecretLocators = append([]store.XWorkmateSecretLocator{}, profile.SecretLocators...)
|
|
} else {
|
|
resolved = store.XWorkmateProfile{}
|
|
}
|
|
|
|
bridgeServerURL := ""
|
|
acpBridgeServerProfiles := []gin.H{}
|
|
if access != nil && access.ProfileScope == store.XWorkmateProfileScopeTenantShared {
|
|
if resolved.OpenclawURL == "" {
|
|
resolved.OpenclawURL = envXWorkmateValue("XWORKMATE_OPENCLAW_URL")
|
|
}
|
|
if resolved.OpenclawOrigin == "" {
|
|
resolved.OpenclawOrigin = envXWorkmateValue("XWORKMATE_OPENCLAW_ORIGIN")
|
|
}
|
|
if resolved.VaultURL == "" {
|
|
resolved.VaultURL = envXWorkmateValue("XWORKMATE_VAULT_ADDR")
|
|
}
|
|
if resolved.VaultNamespace == "" {
|
|
resolved.VaultNamespace = envXWorkmateValue("XWORKMATE_VAULT_NAMESPACE")
|
|
}
|
|
if resolved.ApisixURL == "" {
|
|
resolved.ApisixURL = envXWorkmateValue("XWORKMATE_APISIX_URL")
|
|
}
|
|
bridgeServerURL = envXWorkmateValue("XWORKMATE_BRIDGE_SERVER_URL")
|
|
acpBridgeServerProfiles = buildSharedXWorkmateAcpBridgeServerProfiles()
|
|
}
|
|
|
|
return &resolved, bridgeServerURL, acpBridgeServerProfiles
|
|
}
|
|
|
|
func buildXWorkmateProfileResponse(access *xworkmateAccessContext, profile *store.XWorkmateProfile, bridgeServerURL string, acpBridgeServerProfiles []gin.H, tokenConfigured gin.H) gin.H {
|
|
resolvedProfile := gin.H{
|
|
"openclawUrl": "",
|
|
"openclawOrigin": "",
|
|
"vaultUrl": "",
|
|
"vaultNamespace": "",
|
|
"vaultSecretPath": "",
|
|
"vaultSecretKey": "",
|
|
"secretLocators": []gin.H{},
|
|
"apisixUrl": "",
|
|
"bridgeServerUrl": bridgeServerURL,
|
|
"acpBridgeServerProfiles": acpBridgeServerProfiles,
|
|
}
|
|
if profile != nil {
|
|
resolvedProfile["openclawUrl"] = profile.OpenclawURL
|
|
resolvedProfile["openclawOrigin"] = profile.OpenclawOrigin
|
|
resolvedProfile["vaultUrl"] = profile.VaultURL
|
|
resolvedProfile["vaultNamespace"] = profile.VaultNamespace
|
|
resolvedProfile["vaultSecretPath"] = profile.VaultSecretPath
|
|
resolvedProfile["vaultSecretKey"] = profile.VaultSecretKey
|
|
resolvedProfile["secretLocators"] = buildXWorkmateSecretLocators(profile)
|
|
resolvedProfile["apisixUrl"] = profile.ApisixURL
|
|
}
|
|
|
|
return gin.H{
|
|
"edition": access.Tenant.Edition,
|
|
"tenant": gin.H{
|
|
"id": access.Tenant.ID,
|
|
"name": access.Tenant.Name,
|
|
"domain": access.Domain,
|
|
},
|
|
"membershipRole": access.MembershipRole,
|
|
"profileScope": access.ProfileScope,
|
|
"canEditIntegrations": access.CanEditIntegrations,
|
|
"canManageTenant": access.CanManageTenant,
|
|
"profile": resolvedProfile,
|
|
"tokenConfigured": tokenConfigured,
|
|
}
|
|
}
|
|
|
|
func resolvedXWorkmateProfileUserID(access *xworkmateAccessContext, user *store.User) string {
|
|
if access == nil {
|
|
return ""
|
|
}
|
|
if access.ProfileScope == store.XWorkmateProfileScopeTenantShared {
|
|
return ""
|
|
}
|
|
if user == nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(user.ID)
|
|
}
|
|
|
|
func (h *handler) loadXWorkmateProfile(ctx context.Context, access *xworkmateAccessContext, user *store.User) (*store.XWorkmateProfile, error) {
|
|
profileUserID := resolvedXWorkmateProfileUserID(access, user)
|
|
profile, err := h.store.GetXWorkmateProfile(ctx, access.Tenant.ID, profileUserID, access.ProfileScope)
|
|
if err == nil {
|
|
return profile, nil
|
|
}
|
|
if !errors.Is(err, store.ErrXWorkmateProfileNotFound) {
|
|
return nil, err
|
|
}
|
|
if access.ProfileScope != store.XWorkmateProfileScopeTenantShared {
|
|
return nil, nil
|
|
}
|
|
|
|
legacyProfile, legacyErr := h.store.GetXWorkmateProfile(ctx, access.Tenant.ID, strings.TrimSpace(user.ID), access.ProfileScope)
|
|
if legacyErr != nil {
|
|
if errors.Is(legacyErr, store.ErrXWorkmateProfileNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, legacyErr
|
|
}
|
|
return legacyProfile, nil
|
|
}
|
|
|
|
func (h *handler) ensureXWorkmateVaultService(c *gin.Context) bool {
|
|
if h.xworkmateVaultService != nil {
|
|
return true
|
|
}
|
|
respondError(c, http.StatusServiceUnavailable, "xworkmate_vault_unavailable", "xworkmate vault integration is not configured")
|
|
return false
|
|
}
|
|
|
|
func findStoredXWorkmateSecretLocator(profile *store.XWorkmateProfile, target string) (store.XWorkmateSecretLocator, bool) {
|
|
if profile == nil {
|
|
return store.XWorkmateSecretLocator{}, false
|
|
}
|
|
normalizedTarget := strings.ToLower(strings.TrimSpace(target))
|
|
for _, locator := range profile.SecretLocators {
|
|
if locator.Target != normalizedTarget {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(locator.SecretPath) == "" || strings.TrimSpace(locator.SecretKey) == "" {
|
|
continue
|
|
}
|
|
store.NormalizeXWorkmateSecretLocator(&locator)
|
|
return locator, true
|
|
}
|
|
return store.XWorkmateSecretLocator{}, false
|
|
}
|
|
|
|
func upsertXWorkmateSecretLocator(profile *store.XWorkmateProfile, locator store.XWorkmateSecretLocator) {
|
|
if profile == nil {
|
|
return
|
|
}
|
|
store.NormalizeXWorkmateSecretLocator(&locator)
|
|
for i := range profile.SecretLocators {
|
|
if profile.SecretLocators[i].Target != locator.Target {
|
|
continue
|
|
}
|
|
profile.SecretLocators[i].ID = locator.ID
|
|
profile.SecretLocators[i].Provider = locator.Provider
|
|
profile.SecretLocators[i].SecretPath = locator.SecretPath
|
|
profile.SecretLocators[i].SecretKey = locator.SecretKey
|
|
profile.SecretLocators[i].Required = locator.Required
|
|
store.NormalizeXWorkmateProfile(profile)
|
|
return
|
|
}
|
|
profile.SecretLocators = append(profile.SecretLocators, locator)
|
|
store.NormalizeXWorkmateProfile(profile)
|
|
}
|
|
|
|
func buildXWorkmateSecretStatusPayload(locator store.XWorkmateSecretLocator, configured bool) gin.H {
|
|
managedTarget, _ := findXWorkmateManagedTarget(locator.Target)
|
|
return gin.H{
|
|
"target": locator.Target,
|
|
"configured": configured,
|
|
"state": func() string {
|
|
if configured {
|
|
return "configured"
|
|
}
|
|
return "missing"
|
|
}(),
|
|
"required": managedTarget.Required || locator.Required,
|
|
"locator": gin.H{
|
|
"id": locator.ID,
|
|
"provider": locator.Provider,
|
|
"secretPath": locator.SecretPath,
|
|
"secretKey": locator.SecretKey,
|
|
"target": locator.Target,
|
|
"required": managedTarget.Required || locator.Required,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (h *handler) describeXWorkmateSecrets(ctx context.Context, access *xworkmateAccessContext, user *store.User, profile *store.XWorkmateProfile) ([]gin.H, map[string]bool, error) {
|
|
profileUserID := resolvedXWorkmateProfileUserID(access, user)
|
|
secrets := make([]gin.H, 0, len(xworkmateManagedSecretTargets))
|
|
statusByTarget := make(map[string]bool, len(xworkmateManagedSecretTargets))
|
|
|
|
for _, managedTarget := range xworkmateManagedSecretTargets {
|
|
locator, ok := findStoredXWorkmateSecretLocator(profile, managedTarget.Target)
|
|
if !ok {
|
|
var err error
|
|
locator, err = buildManagedXWorkmateSecretLocator(access, profileUserID, managedTarget.Target)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
configured := false
|
|
if h.xworkmateVaultService != nil {
|
|
var err error
|
|
configured, err = h.xworkmateVaultService.HasSecret(ctx, locator)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
} else {
|
|
configured = statusByTargetFromMetadata(profile, managedTarget.Target)
|
|
}
|
|
|
|
statusByTarget[managedTarget.Target] = configured
|
|
secrets = append(secrets, buildXWorkmateSecretStatusPayload(locator, configured))
|
|
}
|
|
|
|
return secrets, statusByTarget, nil
|
|
}
|
|
|
|
func statusByTargetFromMetadata(profile *store.XWorkmateProfile, target string) bool {
|
|
if profile == nil {
|
|
return false
|
|
}
|
|
if target == store.XWorkmateSecretLocatorTargetOpenclawGatewayToken {
|
|
return hasOpenclawXWorkmateSecretLocator(profile)
|
|
}
|
|
for _, locator := range profile.SecretLocators {
|
|
if locator.Target == strings.ToLower(strings.TrimSpace(target)) &&
|
|
strings.TrimSpace(locator.SecretPath) != "" &&
|
|
strings.TrimSpace(locator.SecretKey) != "" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsForbiddenXWorkmateTokenField(value any) bool {
|
|
switch typed := value.(type) {
|
|
case map[string]any:
|
|
for key, nested := range typed {
|
|
if _, ok := xworkmateForbiddenTokenFields[strings.ToLower(strings.TrimSpace(key))]; ok {
|
|
return true
|
|
}
|
|
if containsForbiddenXWorkmateTokenField(nested) {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, nested := range typed {
|
|
if containsForbiddenXWorkmateTokenField(nested) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *handler) getXWorkmateProfile(c *gin.Context) {
|
|
user, ok := h.currentAuthenticatedUser(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTenantMembershipNotFound) {
|
|
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrTenantNotFound) {
|
|
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
|
|
return
|
|
}
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
|
|
return
|
|
}
|
|
|
|
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
|
|
return
|
|
}
|
|
|
|
tokenConfigured := buildXWorkmateTokenConfigured(profile)
|
|
if h.xworkmateVaultService != nil {
|
|
_, statusByTarget, err := h.describeXWorkmateSecrets(c.Request.Context(), access, user, profile)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_secret_read_failed", "failed to load xworkmate secret status")
|
|
return
|
|
}
|
|
tokenConfigured = buildXWorkmateTokenConfiguredWithVaultStatus(profile, statusByTarget)
|
|
}
|
|
resolvedProfile, bridgeServerURL, acpBridgeServerProfiles := buildResolvedXWorkmateProfile(access, profile)
|
|
|
|
c.JSON(http.StatusOK, buildXWorkmateProfileResponse(access, resolvedProfile, bridgeServerURL, acpBridgeServerProfiles, tokenConfigured))
|
|
}
|
|
|
|
func (h *handler) updateXWorkmateProfile(c *gin.Context) {
|
|
user, ok := h.currentAuthenticatedUser(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTenantMembershipNotFound) {
|
|
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrTenantNotFound) {
|
|
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
|
|
return
|
|
}
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
|
|
return
|
|
}
|
|
|
|
if !access.CanEditIntegrations {
|
|
respondError(c, http.StatusForbidden, "xworkmate_profile_forbidden", "you are not allowed to update integrations for this tenant")
|
|
return
|
|
}
|
|
if h.isReadOnlyAccount(user) {
|
|
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
|
|
return
|
|
}
|
|
|
|
var raw map[string]any
|
|
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
|
|
return
|
|
}
|
|
|
|
if containsForbiddenXWorkmateTokenField(raw) {
|
|
respondError(c, http.StatusBadRequest, "token_persistence_forbidden", "raw token fields cannot be persisted")
|
|
return
|
|
}
|
|
|
|
profileValue, ok := raw["profile"]
|
|
if !ok {
|
|
profileValue = raw
|
|
}
|
|
|
|
encodedProfile, err := json.Marshal(profileValue)
|
|
if err != nil {
|
|
respondError(c, http.StatusBadRequest, "invalid_request", "invalid profile payload")
|
|
return
|
|
}
|
|
|
|
var payload xworkmateProfilePayload
|
|
if err := json.Unmarshal(encodedProfile, &payload); err != nil {
|
|
respondError(c, http.StatusBadRequest, "invalid_request", "invalid profile payload")
|
|
return
|
|
}
|
|
|
|
profileUserID := resolvedXWorkmateProfileUserID(access, user)
|
|
|
|
profile := &store.XWorkmateProfile{
|
|
TenantID: access.Tenant.ID,
|
|
UserID: profileUserID,
|
|
Scope: access.ProfileScope,
|
|
OpenclawURL: payload.OpenclawURL,
|
|
OpenclawOrigin: payload.OpenclawOrigin,
|
|
VaultURL: payload.VaultURL,
|
|
VaultNamespace: payload.VaultNamespace,
|
|
VaultSecretPath: payload.VaultSecretPath,
|
|
VaultSecretKey: payload.VaultSecretKey,
|
|
SecretLocators: buildStoreXWorkmateSecretLocators(payload.SecretLocators),
|
|
ApisixURL: payload.ApisixURL,
|
|
}
|
|
if err := h.store.UpsertXWorkmateProfile(c.Request.Context(), profile); err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_profile_write_failed", "failed to save xworkmate profile")
|
|
return
|
|
}
|
|
|
|
tokenConfigured := buildXWorkmateTokenConfigured(profile)
|
|
if h.xworkmateVaultService != nil {
|
|
_, statusByTarget, err := h.describeXWorkmateSecrets(c.Request.Context(), access, user, profile)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_secret_read_failed", "failed to load xworkmate secret status")
|
|
return
|
|
}
|
|
tokenConfigured = buildXWorkmateTokenConfiguredWithVaultStatus(profile, statusByTarget)
|
|
}
|
|
resolvedProfile, bridgeServerURL, acpBridgeServerProfiles := buildResolvedXWorkmateProfile(access, profile)
|
|
|
|
c.JSON(http.StatusOK, buildXWorkmateProfileResponse(access, resolvedProfile, bridgeServerURL, acpBridgeServerProfiles, tokenConfigured))
|
|
}
|
|
|
|
func (h *handler) getXWorkmateSecrets(c *gin.Context) {
|
|
if !h.ensureXWorkmateVaultService(c) {
|
|
return
|
|
}
|
|
|
|
user, ok := h.currentAuthenticatedUser(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTenantMembershipNotFound) {
|
|
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrTenantNotFound) {
|
|
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
|
|
return
|
|
}
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
|
|
return
|
|
}
|
|
|
|
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
|
|
return
|
|
}
|
|
|
|
secrets, statusByTarget, err := h.describeXWorkmateSecrets(c.Request.Context(), access, user, profile)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_secret_read_failed", "failed to load xworkmate secret status")
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"edition": access.Tenant.Edition,
|
|
"profileScope": access.ProfileScope,
|
|
"membershipRole": access.MembershipRole,
|
|
"canEditIntegrations": access.CanEditIntegrations,
|
|
"canManageTenant": access.CanManageTenant,
|
|
"tenant": gin.H{"id": access.Tenant.ID, "name": access.Tenant.Name, "domain": access.Domain},
|
|
"secrets": secrets,
|
|
"tokenConfigured": buildXWorkmateTokenConfiguredWithVaultStatus(profile, statusByTarget),
|
|
"vaultBackendEnabled": true,
|
|
})
|
|
}
|
|
|
|
func (h *handler) putXWorkmateSecret(c *gin.Context) {
|
|
if !h.ensureXWorkmateVaultService(c) {
|
|
return
|
|
}
|
|
|
|
user, ok := h.currentAuthenticatedUser(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTenantMembershipNotFound) {
|
|
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrTenantNotFound) {
|
|
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
|
|
return
|
|
}
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
|
|
return
|
|
}
|
|
if !access.CanEditIntegrations {
|
|
respondError(c, http.StatusForbidden, "xworkmate_secret_forbidden", "you are not allowed to update integrations for this tenant")
|
|
return
|
|
}
|
|
if h.isReadOnlyAccount(user) {
|
|
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
|
|
return
|
|
}
|
|
|
|
target := strings.ToLower(strings.TrimSpace(c.Param("target")))
|
|
if _, ok := findXWorkmateManagedTarget(target); !ok {
|
|
respondError(c, http.StatusBadRequest, "xworkmate_secret_unknown_target", "unknown xworkmate secret target")
|
|
return
|
|
}
|
|
|
|
var payload struct {
|
|
Value string `json:"value"`
|
|
}
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
|
|
return
|
|
}
|
|
if strings.TrimSpace(payload.Value) == "" {
|
|
respondError(c, http.StatusBadRequest, "xworkmate_secret_value_required", "secret value is required")
|
|
return
|
|
}
|
|
|
|
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
|
|
return
|
|
}
|
|
if profile == nil {
|
|
profile = &store.XWorkmateProfile{
|
|
TenantID: access.Tenant.ID,
|
|
UserID: resolvedXWorkmateProfileUserID(access, user),
|
|
Scope: access.ProfileScope,
|
|
}
|
|
}
|
|
|
|
locator, err := buildManagedXWorkmateSecretLocator(access, resolvedXWorkmateProfileUserID(access, user), target)
|
|
if err != nil {
|
|
respondError(c, http.StatusBadRequest, "xworkmate_secret_unknown_target", "unknown xworkmate secret target")
|
|
return
|
|
}
|
|
if err := h.xworkmateVaultService.WriteSecret(c.Request.Context(), locator, payload.Value); err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_secret_write_failed", "failed to persist xworkmate secret")
|
|
return
|
|
}
|
|
|
|
upsertXWorkmateSecretLocator(profile, locator)
|
|
if err := h.store.UpsertXWorkmateProfile(c.Request.Context(), profile); err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_profile_write_failed", "failed to save xworkmate profile")
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"secret": buildXWorkmateSecretStatusPayload(locator, true),
|
|
"profileScope": access.ProfileScope,
|
|
"tokenConfigured": buildXWorkmateTokenConfiguredWithVaultStatus(profile, map[string]bool{target: true}),
|
|
})
|
|
}
|
|
|
|
func (h *handler) deleteXWorkmateSecret(c *gin.Context) {
|
|
if !h.ensureXWorkmateVaultService(c) {
|
|
return
|
|
}
|
|
|
|
user, ok := h.currentAuthenticatedUser(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
access, err := h.resolveXWorkmateAccess(c.Request.Context(), h.resolveTenantHost(c), user)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrTenantMembershipNotFound) {
|
|
respondError(c, http.StatusForbidden, "tenant_membership_required", "tenant membership is required")
|
|
return
|
|
}
|
|
if errors.Is(err, store.ErrTenantNotFound) {
|
|
respondError(c, http.StatusNotFound, "tenant_not_found", "tenant was not found")
|
|
return
|
|
}
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_context_failed", "failed to resolve xworkmate context")
|
|
return
|
|
}
|
|
if !access.CanEditIntegrations {
|
|
respondError(c, http.StatusForbidden, "xworkmate_secret_forbidden", "you are not allowed to update integrations for this tenant")
|
|
return
|
|
}
|
|
if h.isReadOnlyAccount(user) {
|
|
respondError(c, http.StatusForbidden, "read_only_account", "demo account is read-only")
|
|
return
|
|
}
|
|
|
|
target := strings.ToLower(strings.TrimSpace(c.Param("target")))
|
|
if _, ok := findXWorkmateManagedTarget(target); !ok {
|
|
respondError(c, http.StatusBadRequest, "xworkmate_secret_unknown_target", "unknown xworkmate secret target")
|
|
return
|
|
}
|
|
|
|
profile, err := h.loadXWorkmateProfile(c.Request.Context(), access, user)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_profile_read_failed", "failed to load xworkmate profile")
|
|
return
|
|
}
|
|
|
|
locator, ok := findStoredXWorkmateSecretLocator(profile, target)
|
|
if !ok {
|
|
locator, err = buildManagedXWorkmateSecretLocator(access, resolvedXWorkmateProfileUserID(access, user), target)
|
|
if err != nil {
|
|
respondError(c, http.StatusBadRequest, "xworkmate_secret_unknown_target", "unknown xworkmate secret target")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := h.xworkmateVaultService.DeleteSecret(c.Request.Context(), locator); err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_secret_delete_failed", "failed to delete xworkmate secret")
|
|
return
|
|
}
|
|
|
|
tokenConfigured := buildXWorkmateTokenConfigured(profile)
|
|
if h.xworkmateVaultService != nil {
|
|
_, statusByTarget, err := h.describeXWorkmateSecrets(c.Request.Context(), access, user, profile)
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "xworkmate_secret_read_failed", "failed to load xworkmate secret status")
|
|
return
|
|
}
|
|
tokenConfigured = buildXWorkmateTokenConfiguredWithVaultStatus(profile, statusByTarget)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"secret": buildXWorkmateSecretStatusPayload(locator, false),
|
|
"profileScope": access.ProfileScope,
|
|
"tokenConfigured": tokenConfigured,
|
|
})
|
|
}
|
|
|
|
func (h *handler) bootstrapTenant(c *gin.Context) {
|
|
adminUser, ok := h.requireAdminPermission(c, permissionAdminSettingsWrite)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.isRootAccount(adminUser) {
|
|
respondError(c, http.StatusForbidden, "root_only", "root only")
|
|
return
|
|
}
|
|
|
|
var payload struct {
|
|
Name string `json:"name"`
|
|
AdminUserID string `json:"adminUserId"`
|
|
AdminEmail string `json:"adminEmail"`
|
|
}
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
respondError(c, http.StatusBadRequest, "invalid_request", "invalid request payload")
|
|
return
|
|
}
|
|
|
|
var member *store.User
|
|
var err error
|
|
switch {
|
|
case strings.TrimSpace(payload.AdminUserID) != "":
|
|
member, err = h.store.GetUserByID(c.Request.Context(), strings.TrimSpace(payload.AdminUserID))
|
|
case strings.TrimSpace(payload.AdminEmail) != "":
|
|
member, err = h.store.GetUserByEmail(c.Request.Context(), strings.TrimSpace(payload.AdminEmail))
|
|
default:
|
|
respondError(c, http.StatusBadRequest, "admin_user_required", "adminUserId or adminEmail is required")
|
|
return
|
|
}
|
|
if err != nil {
|
|
respondError(c, http.StatusNotFound, "admin_user_not_found", "admin user not found")
|
|
return
|
|
}
|
|
|
|
domain, err := store.GenerateRandomTenantDomain()
|
|
if err != nil {
|
|
respondError(c, http.StatusInternalServerError, "tenant_domain_generation_failed", "failed to generate tenant domain")
|
|
return
|
|
}
|
|
|
|
tenant := &store.Tenant{
|
|
Name: strings.TrimSpace(payload.Name),
|
|
Edition: store.TenantPrivateEdition,
|
|
}
|
|
if tenant.Name == "" {
|
|
tenant.Name = member.Name
|
|
if tenant.Name == "" {
|
|
tenant.Name = member.Email
|
|
}
|
|
}
|
|
if err := h.store.EnsureTenant(c.Request.Context(), tenant); err != nil {
|
|
respondError(c, http.StatusInternalServerError, "tenant_create_failed", "failed to create tenant")
|
|
return
|
|
}
|
|
if err := h.store.EnsureTenantDomain(c.Request.Context(), &store.TenantDomain{
|
|
TenantID: tenant.ID,
|
|
Domain: domain,
|
|
Kind: store.TenantDomainKindGenerated,
|
|
IsPrimary: true,
|
|
Status: store.TenantDomainStatusVerified,
|
|
}); err != nil {
|
|
respondError(c, http.StatusInternalServerError, "tenant_domain_create_failed", "failed to create tenant domain")
|
|
return
|
|
}
|
|
if err := h.store.UpsertTenantMembership(c.Request.Context(), &store.TenantMembership{
|
|
TenantID: tenant.ID,
|
|
UserID: member.ID,
|
|
Role: store.TenantMembershipRoleAdmin,
|
|
}); err != nil {
|
|
respondError(c, http.StatusInternalServerError, "tenant_membership_create_failed", "failed to create tenant membership")
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"tenant": gin.H{
|
|
"id": tenant.ID,
|
|
"name": tenant.Name,
|
|
"edition": tenant.Edition,
|
|
"domain": domain,
|
|
},
|
|
"member": gin.H{
|
|
"id": member.ID,
|
|
"email": member.Email,
|
|
"role": store.TenantMembershipRoleAdmin,
|
|
},
|
|
})
|
|
}
|