Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ func main() {
serveCmd.Flags().String("hashicorp-approle-secret-id", "", "hashicorp vault AppRole secret ID (auth-method=approle; prefer HILT_VAULT_HASHICORP_APPROLE_SECRET_ID env var or config file)")
serveCmd.Flags().String("hashicorp-approle-mount", "approle", "hashicorp vault AppRole auth mount path")

// plc config
serveCmd.Flags().String("plc-directory", "https://plc.directory", "did:plc directory endpoint")

// auth config
serveCmd.Flags().String("partner-key", "", "partner bearer key required on Tenant API requests (prefer HILT_AUTH_PARTNER_KEY env var or config file to avoid exposing via process args)")

Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.26.4
require (
github.com/docker/docker v28.5.2+incompatible
github.com/fil-forge/libforge v0.0.0-20260623151745-4c28e5a78e9d
github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88
github.com/fil-forge/ucantone v0.0.0-20260626095728-03ab31e14439
github.com/hashicorp/vault-client-go v0.4.3
github.com/ipfs/go-cid v0.6.1
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
Expand Down Expand Up @@ -105,6 +105,8 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/whyrusleeping/cbor-gen v0.3.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fil-forge/libforge v0.0.0-20260623151745-4c28e5a78e9d h1:a2ZVWDpGQ+eOB4tcKMBK/ynHVYTuN+VvWtHxQokUM1Q=
github.com/fil-forge/libforge v0.0.0-20260623151745-4c28e5a78e9d/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M=
github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88 h1:N0gbL3Ik+XBYk4y/5BxTVymwbRGlxRXwC5eNWzi1bGI=
github.com/fil-forge/ucantone v0.0.0-20260619013642-7985ec010b88/go.mod h1:rTIRXz4xErI4U+YlBU9ZvhlTbr4Hs5tJhVMwereVkSg=
github.com/fil-forge/ucantone v0.0.0-20260626095728-03ab31e14439 h1:cex4+Gi3eox1SBPb4hbRgNSS4M4gwUQ14HYVG5SyKiM=
github.com/fil-forge/ucantone v0.0.0-20260626095728-03ab31e14439/go.mod h1:rTIRXz4xErI4U+YlBU9ZvhlTbr4Hs5tJhVMwereVkSg=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
Expand Down
126 changes: 123 additions & 3 deletions pkg/api/tenants.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,135 @@
package api

import (
"errors"
"net/http"

"github.com/fil-forge/hilt/pkg/store"
"github.com/fil-forge/hilt/pkg/store/provider"
"github.com/fil-forge/hilt/pkg/store/tenant"
"github.com/fil-forge/hilt/pkg/vault"
"github.com/fil-forge/ucantone/did"
"github.com/fil-forge/ucantone/did/plc"
"github.com/fil-forge/ucantone/multikey/secp256k1"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
)

// NewProvisionTenantHandler handles PUT /tenants/{tenantId} — provision a
// tenant with full setup.
func NewProvisionTenantHandler(logger *zap.Logger) Route {
return NewRoute(http.MethodPut, "/tenants/:tenantId", notImplemented(logger, "ProvisionTenant"))
// tenant: generate a rotatable did:plc tenant key, publish it to the PLC
// directory, store the private key in the vault, and persist the tenant record.
// It is idempotent on the external {tenantId}.
func NewProvisionTenantHandler(
logger *zap.Logger,
tenants tenant.Store,
providers provider.Store,
v vault.Vault,
plcClient *plc.DirectoryClient,
) Route {
log := logger.With(zap.String("handler", "ProvisionTenant"))
return NewRoute(http.MethodPut, "/tenants/:tenantId", func(c echo.Context) error {
ctx := c.Request().Context()

externalID := c.Param("tenantId")
if externalID == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing tenant id")
}

var req ProvisionTenantRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if req.DisplayName == "" {
return echo.NewHTTPError(http.StatusBadRequest, "displayName is required")
}
if req.Region == "" {
return echo.NewHTTPError(http.StatusBadRequest, "region is required")
}

// Idempotent: return the existing tenant if already provisioned.
if rec, err := tenants.GetByExternalID(ctx, externalID); err == nil {
return c.JSON(http.StatusOK, tenantResponse(rec))
} else if !errors.Is(err, store.ErrRecordNotFound) {
log.Error("looking up tenant", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}

// Resolve the provider for the requested region.
prov, err := providers.GetByRegion(ctx, req.Region)
if errors.Is(err, store.ErrRecordNotFound) {
return echo.NewHTTPError(http.StatusBadRequest, "unknown region")
} else if err != nil {
log.Error("resolving provider", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}

// Generate the tenant's rotatable did:plc key (secp256k1 rotation key).
signer, err := secp256k1.Generate()
if err != nil {
log.Error("generating tenant key", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}
key := signer.KeyDID()
tenantID, genesis, err := plc.New(
signer,
plc.WithRotationKeys([]did.DID{key}),
plc.WithVerificationMethods(map[string]did.DID{"hilt": key}),
)
if err != nil {
log.Error("building genesis operation", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}

log := log.With(zap.String("external_id", externalID), zap.Stringer("tenant", tenantID))

// Persist the private key before publishing so it is never lost. Store
// the multiformat-tagged bytes (signer.Bytes()) so the key type is
// recoverable on decode rather than assuming secp256k1.
vaultKey := "/tenant/" + tenantID.String()
if err := v.Write(ctx, vaultKey, signer.Bytes()); err != nil {
log.Error("storing tenant key", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}

// Publish the genesis operation to register the did:plc.
if err := plcClient.Update(ctx, tenantID, genesis); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add retry with exponential backoff to gracefully handle temporary service degradation or unavailability of PLC?

Also, is there any timeout/deadline enforced for this call to a 3rd-party API?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally I'd like to push retries all the way back to the client so we don't end up adding retry logic for every database/API call we make in these handlers.

I will configure the HTTP client with a timeout though - good call.

log.Error("publishing genesis operation", zap.Error(err))
_ = v.Delete(ctx, vaultKey) // best-effort cleanup of the orphaned key
return echo.NewHTTPError(http.StatusBadGateway, "failed to register tenant DID")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the recovery story for this failure point?

  1. In FilOne Console web app, I create my first bucket in the Forge region
  2. FilOne Console backend calls PUT /tenants/{orgId}
  3. Hilt checks the DB, the tenant is not there yet
  4. Hilt generates a new tenant identity and stores the signer in Vault
  5. Hilt fails to register the tenant with PLC and returns 502 Bad Gateway
  6. FilOne Console backends return a 500 internal error to the user

Since Console didn't provide any details to the user, they may retry the same operation.

  1. I try to create my first bucket in the Forge region again
  2. FilOne Console backend calls PUT /tenants/{orgId}
  3. Hilt checks the DB, the tenant is not there yet
  4. Hilt generates a new tenant identity and stores the signer in Vault
    • The Vault entry created in the first run is orphaned
    • Shouldn't we check the Vault first and use the existing signer if it already exists?
  5. Let's say Hilt succeeds in registering the tenant with PLC now
  6. Hilt stores the tenant info in the DB and all is good (except the orphaned Vault entry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's important to have some unit tests to cover this scenario.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally secrets.Delete(...) will clean up the orphan. I will check the tests exercise failures and assert on the clean up.

}

// Record the tenant.
if err := tenants.Add(ctx, tenantID, externalID, prov.ID, req.DisplayName, tenant.Active); err != nil {
if errors.Is(err, store.ErrRecordExists) {
// Concurrent create with the same external id: return the winner.
if rec, gerr := tenants.GetByExternalID(ctx, externalID); gerr == nil {
return c.JSON(http.StatusOK, tenantResponse(rec))
}
}
Comment thread
Copilot marked this conversation as resolved.
log.Error("storing tenant", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}

rec, err := tenants.Get(ctx, tenantID)
if err != nil {
log.Error("loading created tenant", zap.Error(err))
return echo.NewHTTPError(http.StatusInternalServerError, "internal error")
}
log.Info("provisioned tenant")
return c.JSON(http.StatusCreated, tenantResponse(rec))
})
}

// tenantResponse builds the Tenant API representation from a stored record. The
// caller-facing tenantId is the external id; the did:plc stays internal. Quota
// counts/limits are not tracked yet and are returned as zero.
func tenantResponse(rec tenant.Record) Tenant {
return Tenant{
TenantID: rec.ExternalID,
DisplayName: rec.Name,
Status: TenantStatus(rec.Status),
CreatedAt: rec.CreatedAt,
}
}

// NewGetTenantHandler handles GET /tenants/{tenantId} — retrieve tenant
Expand Down
136 changes: 136 additions & 0 deletions pkg/api/tenants_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package api_test

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"

"github.com/fil-forge/hilt/internal/testutil"
"github.com/fil-forge/hilt/pkg/api"
"github.com/fil-forge/hilt/pkg/store/provider"
providermemory "github.com/fil-forge/hilt/pkg/store/provider/memory"
"github.com/fil-forge/hilt/pkg/store/tenant"
tenantmemory "github.com/fil-forge/hilt/pkg/store/tenant/memory"
"github.com/fil-forge/hilt/pkg/vault"
vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory"
"github.com/fil-forge/ucantone/did/plc"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)

type provisionDeps struct {
tenants tenant.Store
providers provider.Store
vault vault.Vault
plcPosts int
}

// setupProvision builds an echo server with the provision handler wired to
// memory stores/vault and a PLC directory client pointed at an httptest server
// that accepts genesis operations (no real PLC network).
func setupProvision(t *testing.T) (*echo.Echo, *provisionDeps) {
t.Helper()
deps := &provisionDeps{
tenants: tenantmemory.New(),
providers: providermemory.New(),
vault: vaultmemory.New(),
}

plcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
deps.plcPosts++
}
w.WriteHeader(http.StatusOK)
Comment thread
alanshaw marked this conversation as resolved.
}))
t.Cleanup(plcServer.Close)

endpoint, err := url.Parse(plcServer.URL)
require.NoError(t, err)
plcClient, err := plc.NewDirectoryClient(*endpoint)
require.NoError(t, err)

route := api.NewProvisionTenantHandler(zap.NewNop(), deps.tenants, deps.providers, deps.vault, plcClient)
e := echo.New()
e.Add(route.Method, route.Path, route.Handler)
return e, deps
}

func provisionRequest(t *testing.T, e *echo.Echo, tenantID string, body api.ProvisionTenantRequest) *httptest.ResponseRecorder {
t.Helper()
encoded, err := json.Marshal(body)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPut, "/tenants/"+tenantID, bytes.NewReader(encoded))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
return rec
}

func TestProvisionTenantHandler(t *testing.T) {
ctx := t.Context()

t.Run("provisions a new tenant", func(t *testing.T) {
e, deps := setupProvision(t)
require.NoError(t, deps.providers.Add(ctx, testutil.RandomDID(t), "us-east-1"))

rec := provisionRequest(t, e, "tenant-1", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"})
require.Equal(t, http.StatusCreated, rec.Code)
require.Contains(t, rec.Body.String(), `"tenantId":"tenant-1"`)
require.Contains(t, rec.Body.String(), `"displayName":"Acme"`)

// A tenant record exists, keyed by a did:plc, mapped to the external id.
stored, err := deps.tenants.GetByExternalID(ctx, "tenant-1")
require.NoError(t, err)
require.Equal(t, "plc", stored.ID.Method())
require.Equal(t, "tenant-1", stored.ExternalID)
require.Equal(t, tenant.Active, stored.Status)

// The private key was stored in the vault and the genesis op published.
key, err := deps.vault.Read(ctx, "/tenant/"+stored.ID.String())
require.NoError(t, err)
require.NotEmpty(t, key)
require.Equal(t, 1, deps.plcPosts)
})

t.Run("is idempotent on the external id", func(t *testing.T) {
e, deps := setupProvision(t)
require.NoError(t, deps.providers.Add(ctx, testutil.RandomDID(t), "us-east-1"))

first := provisionRequest(t, e, "tenant-2", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"})
require.Equal(t, http.StatusCreated, first.Code)
stored, err := deps.tenants.GetByExternalID(ctx, "tenant-2")
require.NoError(t, err)

second := provisionRequest(t, e, "tenant-2", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"})
require.Equal(t, http.StatusOK, second.Code)

// No new key minted/published on the idempotent call.
require.Equal(t, 1, deps.plcPosts)
again, err := deps.tenants.GetByExternalID(ctx, "tenant-2")
require.NoError(t, err)
require.Equal(t, stored.ID, again.ID)
})

t.Run("unknown region is rejected", func(t *testing.T) {
e, _ := setupProvision(t)
rec := provisionRequest(t, e, "tenant-3", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "nowhere"})
require.Equal(t, http.StatusBadRequest, rec.Code)
})

t.Run("missing displayName is rejected", func(t *testing.T) {
e, deps := setupProvision(t)
require.NoError(t, deps.providers.Add(ctx, testutil.RandomDID(t), "us-east-1"))
rec := provisionRequest(t, e, "tenant-4", api.ProvisionTenantRequest{Region: "us-east-1"})
require.Equal(t, http.StatusBadRequest, rec.Code)
})

t.Run("missing region is rejected", func(t *testing.T) {
e, _ := setupProvision(t)
rec := provisionRequest(t, e, "tenant-5", api.ProvisionTenantRequest{DisplayName: "Acme"})
require.Equal(t, http.StatusBadRequest, rec.Code)
})
}
11 changes: 11 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type Config struct {
Log LogConfig `mapstructure:"log"`
Storage StorageConfig `mapstructure:"storage"`
Vault VaultConfig `mapstructure:"vault"`
PLC PLCConfig `mapstructure:"plc"`
Auth AuthConfig `mapstructure:"auth"`
}

Expand All @@ -54,6 +55,13 @@ type LogConfig struct {
Level string `mapstructure:"level"`
}

// PLCConfig holds settings for the did:plc directory.
type PLCConfig struct {
// Directory is the did:plc directory endpoint used to resolve and publish
// PLC operations, e.g. "https://plc.directory".
Directory string `mapstructure:"directory"`
}

// StorageConfig selects and configures the store backend.
type StorageConfig struct {
// Type selects the backend: "memory" or "postgres". Defaults to "postgres".
Expand Down Expand Up @@ -122,6 +130,8 @@ func SetDefaults(v *viper.Viper) {
v.SetDefault("vault.hashicorp.mount", "secret")
v.SetDefault("vault.hashicorp.auth_method", VaultAuthAppRole)
v.SetDefault("vault.hashicorp.approle.mount", "approle")

v.SetDefault("plc.directory", "https://plc.directory")
}

// BindEnvVars sets up environment variable binding with the HILT_ prefix.
Expand Down Expand Up @@ -149,6 +159,7 @@ func BindFlags(v *viper.Viper, flags *pflag.FlagSet) error {
"vault.hashicorp.approle.role_id": "hashicorp-approle-role-id",
"vault.hashicorp.approle.secret_id": "hashicorp-approle-secret-id",
"vault.hashicorp.approle.mount": "hashicorp-approle-mount",
"plc.directory": "plc-directory",
"auth.partner_key": "partner-key",
}
for key, name := range bindings {
Expand Down
1 change: 1 addition & 0 deletions pkg/fx/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func AppModule(cfg *config.Config) fx.Option {
fx.Supply(cfg),
ConfigModule,
LoggerModule,
PLCModule,
APIModule,
ServerModule,
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/fx/config.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package fx

import (
Expand All @@ -20,6 +20,7 @@
Postgres config.PostgresConfig
Vault config.VaultConfig
Hashicorp config.HashicorpConfig
PLC config.PLCConfig
Auth config.AuthConfig
}

Expand All @@ -32,6 +33,7 @@
Postgres: cfg.Storage.Postgres,
Vault: cfg.Vault,
Hashicorp: cfg.Vault.Hashicorp,
PLC: cfg.PLC,
Auth: cfg.Auth,
}
}
Loading
Loading