-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement tenant provisioning handler #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
c69142e
3bd8a33
f3d1d7c
c8448f7
f795602
3503809
00e7369
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
| 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") | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the recovery story for this failure point?
Since Console didn't provide any details to the user, they may retry the same operation.
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally |
||
| } | ||
|
|
||
| // 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)) | ||
| } | ||
| } | ||
|
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 | ||
|
|
||
| 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) | ||
|
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) | ||
| }) | ||
| } | ||
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.