diff --git a/cmd/main.go b/cmd/main.go index 9055031..ee83b8c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -28,6 +28,10 @@ func main() { RunE: runServe, } + // identity config (UCAN RPC service identity) + serveCmd.Flags().String("identity-key-file", "", "path to a PEM-encoded Ed25519 private key for the Hilt service identity (an ephemeral key is generated if unset)") + serveCmd.Flags().String("identity-service-id", "", "optional did:web service identity to wrap the key with, e.g. did:web:hilt.example.com") + // http server config serveCmd.Flags().String("host", "127.0.0.1", "host to bind the server to") serveCmd.Flags().Int("port", 8080, "port to bind the server to") @@ -47,6 +51,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)") diff --git a/go.mod b/go.mod index e4fae66..6061158 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,14 @@ 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/libforge v0.0.0-20260701162346-f0706e1641a3 + github.com/fil-forge/ucantone v0.0.0-20260630103048-a8f24fe31eb6 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 github.com/jackc/pgx/v5 v5.8.0 github.com/labstack/echo/v4 v4.15.0 + github.com/multiformats/go-multibase v0.3.0 github.com/pressly/goose/v3 v3.27.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -26,7 +27,7 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/alanshaw/dag-json-gen v0.0.6 // indirect + github.com/alanshaw/dag-json-gen v0.0.8 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -79,7 +80,6 @@ require ( github.com/mr-tron/base58 v1.3.0 // indirect github.com/multiformats/go-base32 v0.1.0 // indirect github.com/multiformats/go-base36 v0.2.0 // indirect - github.com/multiformats/go-multibase v0.3.0 // indirect github.com/multiformats/go-multicodec v0.10.0 // indirect github.com/multiformats/go-multihash v0.2.3 // indirect github.com/multiformats/go-varint v0.1.0 // indirect @@ -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 @@ -125,5 +127,5 @@ require ( golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gopkg.in/yaml.v3 v3.0.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect - pitr.ca/jsontokenizer v0.3.0 // indirect + pitr.ca/jsontokenizer v0.3.2 // indirect ) diff --git a/go.sum b/go.sum index 795aece..f036f69 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/alanshaw/dag-json-gen v0.0.6 h1:MiscvWVOhs6/ux7OUdPz2nDRA7GwklZyaAy4XWexpr0= -github.com/alanshaw/dag-json-gen v0.0.6/go.mod h1:rXxWw0SItP9QjxpRMpkju66h0KumF7TPCtvHdOKS5lY= +github.com/alanshaw/dag-json-gen v0.0.8 h1:Y0SfO2bp9ECDvcNbw6aQ91jmnfLC7UgRDshCeASDfT8= +github.com/alanshaw/dag-json-gen v0.0.8/go.mod h1:v1YBZcS4B355MqxtyQr+fGNbEhm0CzHd+gOqOO/MZ+I= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -45,10 +45,10 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 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/libforge v0.0.0-20260701162346-f0706e1641a3 h1:/EDxpbuVeSXH3FLF7MK4G4wrDUFmCO9fLQHL23RZ2uU= +github.com/fil-forge/libforge v0.0.0-20260701162346-f0706e1641a3/go.mod h1:0kXihIQ4L2uZ00nR5XrZ/Y8Db7Ht/qQNuiWslwMJ95M= +github.com/fil-forge/ucantone v0.0.0-20260630103048-a8f24fe31eb6 h1:0k57ScNcbxTrveaJhos1veTS7jm4RXt2VBofNK+60ko= +github.com/fil-forge/ucantone v0.0.0-20260630103048-a8f24fe31eb6/go.mod h1:oFY5BfD0bDeodGlbBHh3/nK99MAS93rGXjoQz7s5qgE= 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= @@ -326,5 +326,5 @@ modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= -pitr.ca/jsontokenizer v0.3.0 h1:Qr70hk4/wcpFEgu/6aJ+nvYQ6x/xS0WOkC627ceiI/M= -pitr.ca/jsontokenizer v0.3.0/go.mod h1:3DJdA2QNOU6cI0XkH6pRKZ4Oe8G5SDRUQ6PFAwaQ3YY= +pitr.ca/jsontokenizer v0.3.2 h1:kzsKwfkWPV5XCmAF//hznh3L9IXbiL8aHbI0FswRkCs= +pitr.ca/jsontokenizer v0.3.2/go.mod h1:3DJdA2QNOU6cI0XkH6pRKZ4Oe8G5SDRUQ6PFAwaQ3YY= diff --git a/pkg/api/access_keys.go b/pkg/api/access_keys.go index 0db5416..8174418 100644 --- a/pkg/api/access_keys.go +++ b/pkg/api/access_keys.go @@ -1,31 +1,406 @@ package api import ( + "context" + "errors" + "maps" "net/http" + "slices" + "github.com/fil-forge/hilt/pkg/store" + "github.com/fil-forge/hilt/pkg/store/accesskey" + "github.com/fil-forge/hilt/pkg/store/bucket" + delegationstore "github.com/fil-forge/hilt/pkg/store/delegation" + "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/multikey" + "github.com/fil-forge/ucantone/multikey/ed25519" + "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/labstack/echo/v4" + "github.com/multiformats/go-multibase" "go.uber.org/zap" ) +const maxAccessKeyNameLength = 100 + +// vaultTenantKeyPath is the vault key under which a tenant's private key is +// stored. +func vaultTenantKeyPath(tenantDID did.DID) string { + return "/tenant/" + tenantDID.String() +} + +// vaultAccessKeyPath is the vault key under which an access key's private key is +// stored. It MUST match the path used by the tenant delete cascade. +func vaultAccessKeyPath(tenantDID, accessKeyDID did.DID) string { + return vaultTenantKeyPath(tenantDID) + "/access/" + accessKeyDID.String() +} + // NewCreateAccessKeyHandler handles POST /tenants/{tenantId}/access-keys — -// create an S3 access-key pair (returns the secret once only). -func NewCreateAccessKeyHandler(logger *zap.Logger) Route { - return NewRoute(http.MethodPost, "/tenants/:tenantId/access-keys", notImplemented(logger, "CreateAccessKey")) +// create an S3 access-key pair (returns the secret once only) and issue the +// tenant→access-key UCAN delegations for the requested permissions. +func NewCreateAccessKeyHandler( + logger *zap.Logger, + tenants tenant.Store, + accessKeys accesskey.Store, + buckets bucket.Store, + delegations delegationstore.Store, + secrets vault.Vault, +) Route { + log := logger.With(zap.String("handler", "CreateAccessKey")) + return NewRoute(http.MethodPost, "/tenants/:tenantId/access-keys", func(c echo.Context) error { + ctx := c.Request().Context() + + var req CreateAccessKeyRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + if req.Name == "" || len(req.Name) > maxAccessKeyNameLength { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "name must be between 1 and 100 characters") + } + if len(req.Permissions) == 0 { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "at least one permission is required") + } + for _, p := range req.Permissions { + if !validS3Permission(p) { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "unknown permission: "+p) + } + } + + tenantRec, err := tenants.GetByExternalID(ctx, c.Param("tenantId")) + if errors.Is(err, store.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } else if err != nil { + log.Error("looking up tenant", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + log := log.With(zap.Stringer("tenant", tenantRec.ID)) + + // Load the tenant signer up front: it is required to issue delegations and + // its absence is unrecoverable, so fail before creating any state. + tenantKeyBytes, err := secrets.Read(ctx, vaultTenantKeyPath(tenantRec.ID)) + if err != nil { + log.Error("reading tenant key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + tenantSigner, err := secp256k1.Decode(tenantKeyBytes) + if err != nil { + log.Error("decoding tenant key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + issuer := multikey.NewIssuer(tenantRec.ID, tenantSigner) + + // Resolve the named buckets to DIDs in a single tenant-scoped list query. + // The query is scoped to the tenant, so a name owned by another tenant (or + // one that doesn't exist) simply won't come back. An empty list means + // tenant-wide (powerline) access. + bucketDIDs := make([]did.DID, 0, len(req.Buckets)) + if len(req.Buckets) > 0 { + recs, err := store.Collect(ctx, func(ctx context.Context, opts store.PaginationConfig) (store.Page[bucket.Record], error) { + listOpts := []bucket.ListOption{bucket.WithNames(req.Buckets...)} + if opts.Cursor != nil { + listOpts = append(listOpts, bucket.WithCursor(*opts.Cursor)) + } + return buckets.ListByTenant(ctx, tenantRec.ID, listOpts...) + }) + if err != nil { + log.Error("resolving buckets", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + byName := make(map[string]did.DID, len(recs)) + for _, b := range recs { + byName[b.Name] = b.ID + } + for _, name := range req.Buckets { + id, ok := byName[name] + if !ok { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "unknown bucket: "+name) + } + bucketDIDs = append(bucketDIDs, id) + } + } + + // Generate the ed25519 access key. accessKeyId is the bare did:key + // identifier; secretAccessKey is the multibase base64url private key. + signer, err := ed25519.Generate() + if err != nil { + log.Error("generating access key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + accessKeyDID := signer.KeyDID() + secretAccessKey, err := multibase.Encode(multibase.Base64url, signer.Bytes()) + if err != nil { + log.Error("encoding secret access key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + log = log.With(zap.Stringer("access_key", accessKeyDID)) + + vaultPath := vaultAccessKeyPath(tenantRec.ID, accessKeyDID) + if err := secrets.Write(ctx, vaultPath, signer.Bytes()); err != nil { + log.Error("storing access key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + // Best-effort rollback of the (idempotent) state created below, so a + // partial failure leaves nothing behind and is retryable. + rollback := func() { + if err := delegations.DeleteByAudience(ctx, accessKeyDID); err != nil { + log.Warn("rollback: deleting delegations", zap.Error(err)) + } + if err := accessKeys.Delete(ctx, accessKeyDID); err != nil { + log.Warn("rollback: deleting access key", zap.Error(err)) + } + if err := secrets.Delete(ctx, vaultPath); err != nil { + log.Warn("rollback: deleting access key from vault", zap.Error(err)) + } + } + + if err := accessKeys.Add(ctx, accessKeyDID, tenantRec.ID, req.Name, bucketDIDs, req.Permissions, req.ExpiresAt); err != nil { + rollback() + // Name uniqueness is enforced by the store's (tenant, name) constraint; + // a fresh random access-key DID colliding is not a realistic case. + if errors.Is(err, store.ErrRecordExists) { + return echo.NewHTTPError(http.StatusConflict, "an access key with this name already exists") + } + log.Error("storing access key record", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + // Issue tenant→access-key delegations: one per (command × subject), where + // subject is each bucket DID or a single powerline (undefined subject). + var opts []delegation.Option + if req.ExpiresAt != nil { + opts = append(opts, delegation.WithExpiration(ucan.UnixTimestamp(req.ExpiresAt.Unix()))) + } + subjects := bucketDIDs + if len(subjects) == 0 { + subjects = []did.DID{did.Undef} // powerline: undefined subject + } + var dels []ucan.Delegation + for _, sub := range subjects { + for _, cmd := range commandsForPermissions(req.Permissions) { + d, err := delegation.Delegate(issuer, accessKeyDID, sub, cmd, opts...) + if err != nil { + rollback() + log.Error("issuing delegation", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + dels = append(dels, d) + } + } + if len(dels) > 0 { + if err := delegations.PutBatch(ctx, dels); err != nil { + rollback() + log.Error("storing delegations", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + } + + rec, err := accessKeys.Get(ctx, accessKeyDID) + if err != nil { + log.Error("loading created access key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + log.Info("created access key") + return c.JSON(http.StatusCreated, CreatedAccessKey{ + AccessKey: AccessKey{ + AccessKeyID: rec.ID.Identifier(), + Name: rec.Name, + Permissions: rec.Permissions, + Buckets: req.Buckets, + ExpiresAt: rec.ExpiresAt, + CreatedAt: rec.CreatedAt, + }, + SecretAccessKey: secretAccessKey, + }) + }) } // NewListAccessKeysHandler handles GET /tenants/{tenantId}/access-keys — list // all S3 access keys for a tenant (excludes secrets). -func NewListAccessKeysHandler(logger *zap.Logger) Route { - return NewRoute(http.MethodGet, "/tenants/:tenantId/access-keys", notImplemented(logger, "ListAccessKeys")) +func NewListAccessKeysHandler( + logger *zap.Logger, + tenants tenant.Store, + accessKeys accesskey.Store, + buckets bucket.Store, +) Route { + log := logger.With(zap.String("handler", "ListAccessKeys")) + return NewRoute(http.MethodGet, "/tenants/:tenantId/access-keys", func(c echo.Context) error { + ctx := c.Request().Context() + + tenantRec, err := tenants.GetByExternalID(ctx, c.Param("tenantId")) + if errors.Is(err, store.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } else if err != nil { + log.Error("looking up tenant", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + recs, err := accessKeys.ListByTenant(ctx, tenantRec.ID) + if err != nil { + log.Error("listing access keys", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + // Resolve names only for the buckets actually referenced across all keys. + bucketIDs := map[did.DID]struct{}{} + for _, rec := range recs { + for _, b := range rec.Buckets { + if _, ok := bucketIDs[b]; !ok { + bucketIDs[b] = struct{}{} + } + } + } + bucketNames, err := bucketNamesByID(ctx, buckets, tenantRec.ID, slices.Collect(maps.Keys(bucketIDs))) + if err != nil { + log.Error("listing buckets", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + items := make([]AccessKey, 0, len(recs)) + for _, rec := range recs { + items = append(items, accessKeyResponse(rec, bucketNames)) + } + return c.JSON(http.StatusOK, AccessKeyList{Items: items}) + }) } // NewGetAccessKeyHandler handles GET /tenants/{tenantId}/access-keys/{accessKeyId} // — retrieve access-key metadata (secret never returned). -func NewGetAccessKeyHandler(logger *zap.Logger) Route { - return NewRoute(http.MethodGet, "/tenants/:tenantId/access-keys/:accessKeyId", notImplemented(logger, "GetAccessKey")) +func NewGetAccessKeyHandler( + logger *zap.Logger, + tenants tenant.Store, + accessKeys accesskey.Store, + buckets bucket.Store, +) Route { + log := logger.With(zap.String("handler", "GetAccessKey")) + return NewRoute(http.MethodGet, "/tenants/:tenantId/access-keys/:accessKeyId", func(c echo.Context) error { + ctx := c.Request().Context() + + tenantRec, err := tenants.GetByExternalID(ctx, c.Param("tenantId")) + if errors.Is(err, store.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } else if err != nil { + log.Error("looking up tenant", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + accessKeyDID, err := did.Parse(did.KeyPrefix + c.Param("accessKeyId")) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, "access key not found") + } + rec, err := accessKeys.Get(ctx, accessKeyDID) + if errors.Is(err, store.ErrRecordNotFound) || (err == nil && rec.Tenant != tenantRec.ID) { + return echo.NewHTTPError(http.StatusNotFound, "access key not found") + } else if err != nil { + log.Error("looking up access key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + bucketNames, err := bucketNamesByID(ctx, buckets, tenantRec.ID, rec.Buckets) + if err != nil { + log.Error("listing buckets", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + return c.JSON(http.StatusOK, accessKeyResponse(rec, bucketNames)) + }) } // NewDeleteAccessKeyHandler handles DELETE /tenants/{tenantId}/access-keys/{accessKeyId} -// — revoke an S3 access key (idempotent). -func NewDeleteAccessKeyHandler(logger *zap.Logger) Route { - return NewRoute(http.MethodDelete, "/tenants/:tenantId/access-keys/:accessKeyId", notImplemented(logger, "DeleteAccessKey")) +// — revoke an S3 access key (idempotent): remove its delegations, vault key, and +// record. Sending UCAN revocations to a revocation service is out of scope (no +// such service exists yet, as with Sprue deprovisioning). +func NewDeleteAccessKeyHandler( + logger *zap.Logger, + tenants tenant.Store, + accessKeys accesskey.Store, + delegations delegationstore.Store, + secrets vault.Vault, +) Route { + log := logger.With(zap.String("handler", "DeleteAccessKey")) + return NewRoute(http.MethodDelete, "/tenants/:tenantId/access-keys/:accessKeyId", func(c echo.Context) error { + ctx := c.Request().Context() + + tenantRec, err := tenants.GetByExternalID(ctx, c.Param("tenantId")) + if errors.Is(err, store.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } else if err != nil { + log.Error("looking up tenant", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + accessKeyDID, err := did.Parse(did.KeyPrefix + c.Param("accessKeyId")) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, "access key not found") // unparseable id ⇒ nothing to delete + } + + // Idempotent: a missing key (or one owned by another tenant) is a no-op. + rec, err := accessKeys.Get(ctx, accessKeyDID) + if errors.Is(err, store.ErrRecordNotFound) || (err == nil && rec.Tenant != tenantRec.ID) { + return echo.NewHTTPError(http.StatusNotFound, "access key not found") + } else if err != nil { + log.Error("looking up access key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + if err := delegations.DeleteByAudience(ctx, accessKeyDID); err != nil { + log.Error("deleting access key delegations", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + if err := secrets.Delete(ctx, vaultAccessKeyPath(tenantRec.ID, accessKeyDID)); err != nil { + log.Warn("removing access key from vault", zap.Error(err)) + } + if err := accessKeys.Delete(ctx, accessKeyDID); err != nil { + log.Error("deleting access key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + return c.NoContent(http.StatusNoContent) + }) +} + +// accessKeyResponse builds the API representation of an access key, resolving +// stored bucket DIDs back to their names (a DID with no known name is rendered +// as the DID string). The secret is never included. +func accessKeyResponse(rec accesskey.Record, bucketNames map[did.DID]string) AccessKey { + var bucketList []string + for _, b := range rec.Buckets { + if name, ok := bucketNames[b]; ok { + bucketList = append(bucketList, name) + } else { + bucketList = append(bucketList, b.String()) + } + } + return AccessKey{ + AccessKeyID: rec.ID.Identifier(), + Name: rec.Name, + Permissions: rec.Permissions, + Buckets: bucketList, + ExpiresAt: rec.ExpiresAt, + CreatedAt: rec.CreatedAt, + } +} + +// bucketNamesByID returns a DID→name map for the given bucket IDs owned by the +// tenant. IDs that don't resolve (e.g. a deleted bucket) are simply absent. +func bucketNamesByID(ctx context.Context, buckets bucket.Store, tenantID did.DID, ids []did.DID) (map[did.DID]string, error) { + if len(ids) == 0 { + return map[did.DID]string{}, nil + } + recs, err := store.Collect(ctx, func(ctx context.Context, opts store.PaginationConfig) (store.Page[bucket.Record], error) { + listOpts := []bucket.ListOption{bucket.WithIDs(ids...)} + if opts.Cursor != nil { + listOpts = append(listOpts, bucket.WithCursor(*opts.Cursor)) + } + return buckets.ListByTenant(ctx, tenantID, listOpts...) + }) + if err != nil { + return nil, err + } + names := make(map[did.DID]string, len(recs)) + for _, b := range recs { + names[b.ID] = b.Name + } + return names, nil } diff --git a/pkg/api/access_keys_test.go b/pkg/api/access_keys_test.go new file mode 100644 index 0000000..c693e6e --- /dev/null +++ b/pkg/api/access_keys_test.go @@ -0,0 +1,333 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/fil-forge/hilt/internal/testutil" + "github.com/fil-forge/hilt/pkg/api" + "github.com/fil-forge/hilt/pkg/store" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/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" + "github.com/fil-forge/ucantone/did/plc" + "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/fil-forge/ucantone/ucan" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +type accessKeyDeps struct { + tenants *tenantmemory.Store + accessKeys *accesskeymemory.Store + buckets *bucketmemory.Store + delegations *delegationmemory.Store + vault vault.Vault + tenantID did.DID // owner of "tenant-1" + "bucket-a" + bucketID did.DID // "bucket-a", owned by tenant-1 + otherBucket string // "bucket-b", owned by a different tenant +} + +// addTenant creates a tenant with a real did:plc key written to the vault and a +// single bucket owned by it, returning the tenant DID and bucket DID. +func addTenant(t *testing.T, deps *accessKeyDeps, externalID, bucketName string) (did.DID, did.DID) { + t.Helper() + ctx := t.Context() + signer, err := secp256k1.Generate() + require.NoError(t, err) + key := signer.KeyDID() + tenantID, _, err := plc.New(signer, + plc.WithRotationKeys(key), + plc.WithVerificationMethods(map[string]did.DID{"hilt": key}), + ) + require.NoError(t, err) + require.NoError(t, deps.tenants.Add(ctx, tenantID, externalID, testutil.RandomDID(t), "Acme", tenant.Active)) + require.NoError(t, deps.vault.Write(ctx, "/tenant/"+tenantID.String(), signer.Bytes())) + bucketID := testutil.RandomDID(t) + require.NoError(t, deps.buckets.Add(ctx, bucketID, tenantID, bucketName)) + return tenantID, bucketID +} + +func setupAccessKeys(t *testing.T) (*echo.Echo, *accessKeyDeps) { + t.Helper() + deps := &accessKeyDeps{ + tenants: tenantmemory.New(), + accessKeys: accesskeymemory.New(), + buckets: bucketmemory.New(), + delegations: delegationmemory.New(), + vault: vaultmemory.New(), + otherBucket: "bucket-b", + } + deps.tenantID, deps.bucketID = addTenant(t, deps, "tenant-1", "bucket-a") + addTenant(t, deps, "tenant-2", deps.otherBucket) // a foreign tenant + bucket + + e := echo.New() + for _, r := range []api.Route{ + api.NewCreateAccessKeyHandler(zap.NewNop(), deps.tenants, deps.accessKeys, deps.buckets, deps.delegations, deps.vault), + api.NewListAccessKeysHandler(zap.NewNop(), deps.tenants, deps.accessKeys, deps.buckets), + api.NewGetAccessKeyHandler(zap.NewNop(), deps.tenants, deps.accessKeys, deps.buckets), + api.NewDeleteAccessKeyHandler(zap.NewNop(), deps.tenants, deps.accessKeys, deps.delegations, deps.vault), + } { + e.Add(r.Method, r.Path, r.Handler) + } + return e, deps +} + +func createAccessKey(t *testing.T, e *echo.Echo, tenantID string, body api.CreateAccessKeyRequest) *httptest.ResponseRecorder { + t.Helper() + enc, err := json.Marshal(body) + require.NoError(t, err) + return doRequest(t, e, http.MethodPost, "/tenants/"+tenantID+"/access-keys", enc) +} + +func TestCreateAccessKeyHandler(t *testing.T) { + ctx := t.Context() + + t.Run("creates a bucket-scoped key and issues delegations", func(t *testing.T) { + e, deps := setupAccessKeys(t) + rec := createAccessKey(t, e, "tenant-1", api.CreateAccessKeyRequest{ + Name: "k1", + Permissions: []string{"s3:GetObject", "s3:PutObject"}, + Buckets: []string{"bucket-a"}, + }) + require.Equal(t, http.StatusCreated, rec.Code) + + var created api.CreatedAccessKey + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + require.NotEmpty(t, created.AccessKeyID) + require.True(t, strings.HasPrefix(created.SecretAccessKey, "u"), "secret is multibase base64url") + require.Equal(t, []string{"bucket-a"}, created.Buckets) + require.Nil(t, created.ExpiresAt) + + akDID, err := did.Parse(did.KeyPrefix + created.AccessKeyID) + require.NoError(t, err) + + // Record persisted. + storedRec, err := deps.accessKeys.Get(ctx, akDID) + require.NoError(t, err) + require.Equal(t, "k1", storedRec.Name) + require.Equal(t, []did.DID{deps.bucketID}, storedRec.Buckets) + + // Private key in the vault. + _, err = deps.vault.Read(ctx, "/tenant/"+deps.tenantID.String()+"/access/"+akDID.String()) + require.NoError(t, err) + + // 4 delegations: /content/retrieve + /blob/add + /index/add + /upload/add, + // all scoped to the bucket, issued by the tenant to the access key. + dels, err := deps.delegations.ListByAudience(ctx, akDID) + require.NoError(t, err) + require.Len(t, dels.Results, 4) + cmds := map[string]bool{} + for _, d := range dels.Results { + cmds[d.Command().String()] = true + require.Equal(t, deps.bucketID, d.Subject()) + require.Equal(t, akDID, d.Audience()) + require.Equal(t, deps.tenantID, d.Issuer()) + } + require.Equal(t, map[string]bool{ + "/content/retrieve": true, + "/blob/add": true, + "/index/add": true, + "/upload/add": true, + }, cmds) + }) + + t.Run("tenant-wide key issues powerline delegations", func(t *testing.T) { + e, deps := setupAccessKeys(t) + rec := createAccessKey(t, e, "tenant-1", api.CreateAccessKeyRequest{ + Name: "wide", + Permissions: []string{"s3:GetObject"}, + }) + require.Equal(t, http.StatusCreated, rec.Code) + var created api.CreatedAccessKey + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + require.Empty(t, created.Buckets) + + akDID, err := did.Parse(did.KeyPrefix + created.AccessKeyID) + require.NoError(t, err) + dels, err := deps.delegations.ListByAudience(ctx, akDID) + require.NoError(t, err) + require.Len(t, dels.Results, 1) + require.False(t, dels.Results[0].Subject().Defined(), "powerline subject is undefined") + }) + + t.Run("permissions without a Forge command issue no delegations", func(t *testing.T) { + e, deps := setupAccessKeys(t) + rec := createAccessKey(t, e, "tenant-1", api.CreateAccessKeyRequest{ + Name: "buckets-only", + Permissions: []string{"s3:CreateBucket", "s3:ListAllMyBuckets"}, + }) + require.Equal(t, http.StatusCreated, rec.Code) + var created api.CreatedAccessKey + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + + akDID, err := did.Parse(did.KeyPrefix + created.AccessKeyID) + require.NoError(t, err) + dels, err := deps.delegations.ListByAudience(ctx, akDID) + require.NoError(t, err) + require.Empty(t, dels.Results) + + // Still retrievable, with the permissions stored. + got, err := deps.accessKeys.Get(ctx, akDID) + require.NoError(t, err) + require.ElementsMatch(t, []string{"s3:CreateBucket", "s3:ListAllMyBuckets"}, got.Permissions) + }) + + t.Run("expiry is persisted, returned, and set on delegations", func(t *testing.T) { + e, deps := setupAccessKeys(t) + exp := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second) + rec := createAccessKey(t, e, "tenant-1", api.CreateAccessKeyRequest{ + Name: "expiring", + Permissions: []string{"s3:GetObject"}, + Buckets: []string{"bucket-a"}, + ExpiresAt: &exp, + }) + require.Equal(t, http.StatusCreated, rec.Code) + var created api.CreatedAccessKey + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created)) + require.NotNil(t, created.ExpiresAt) + require.True(t, exp.Equal(*created.ExpiresAt)) + + akDID, err := did.Parse(did.KeyPrefix + created.AccessKeyID) + require.NoError(t, err) + got, err := deps.accessKeys.Get(ctx, akDID) + require.NoError(t, err) + require.NotNil(t, got.ExpiresAt) + require.True(t, exp.Equal(*got.ExpiresAt)) + + dels, err := deps.delegations.ListByAudience(ctx, akDID) + require.NoError(t, err) + require.Len(t, dels.Results, 1) + require.NotNil(t, dels.Results[0].Expiration()) + require.Equal(t, ucan.UnixTimestamp(exp.Unix()), *dels.Results[0].Expiration()) + }) + + t.Run("duplicate name is rejected", func(t *testing.T) { + e, _ := setupAccessKeys(t) + body := api.CreateAccessKeyRequest{Name: "dup", Permissions: []string{"s3:GetObject"}} + require.Equal(t, http.StatusCreated, createAccessKey(t, e, "tenant-1", body).Code) + require.Equal(t, http.StatusConflict, createAccessKey(t, e, "tenant-1", body).Code) + }) + + t.Run("unknown tenant is 404", func(t *testing.T) { + e, _ := setupAccessKeys(t) + rec := createAccessKey(t, e, "missing", api.CreateAccessKeyRequest{Name: "k", Permissions: []string{"s3:GetObject"}}) + require.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("invalid requests are 422", func(t *testing.T) { + e, _ := setupAccessKeys(t) + cases := map[string]api.CreateAccessKeyRequest{ + "empty name": {Name: "", Permissions: []string{"s3:GetObject"}}, + "empty permissions": {Name: "k", Permissions: nil}, + "unknown permission": {Name: "k", Permissions: []string{"s3:Frobnicate"}}, + "unknown bucket": {Name: "k", Permissions: []string{"s3:GetObject"}, Buckets: []string{"ghost"}}, + "foreign bucket": {Name: "k", Permissions: []string{"s3:GetObject"}, Buckets: []string{"bucket-b"}}, + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + rec := createAccessKey(t, e, "tenant-1", body) + require.Equal(t, http.StatusUnprocessableEntity, rec.Code) + }) + } + }) +} + +func TestListAccessKeysHandler(t *testing.T) { + e, _ := setupAccessKeys(t) + require.Equal(t, http.StatusCreated, createAccessKey(t, e, "tenant-1", api.CreateAccessKeyRequest{Name: "a", Permissions: []string{"s3:GetObject"}, Buckets: []string{"bucket-a"}}).Code) + require.Equal(t, http.StatusCreated, createAccessKey(t, e, "tenant-1", api.CreateAccessKeyRequest{Name: "b", Permissions: []string{"s3:PutObject"}}).Code) + + t.Run("lists keys without secrets", func(t *testing.T) { + rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-1/access-keys", nil) + require.Equal(t, http.StatusOK, rec.Code) + require.NotContains(t, rec.Body.String(), "secretAccessKey") + + var list api.AccessKeyList + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &list)) + require.Len(t, list.Items, 2) + names := map[string][]string{} + for _, k := range list.Items { + names[k.Name] = k.Buckets + } + require.Equal(t, []string{"bucket-a"}, names["a"]) // bucket DID resolved back to name + require.Empty(t, names["b"]) + }) + + t.Run("unknown tenant is 404", func(t *testing.T) { + rec := doRequest(t, e, http.MethodGet, "/tenants/missing/access-keys", nil) + require.Equal(t, http.StatusNotFound, rec.Code) + }) +} + +func TestGetAccessKeyHandler(t *testing.T) { + e, _ := setupAccessKeys(t) + created := createAccessKey(t, e, "tenant-1", api.CreateAccessKeyRequest{Name: "g", Permissions: []string{"s3:GetObject"}, Buckets: []string{"bucket-a"}}) + require.Equal(t, http.StatusCreated, created.Code) + var ck api.CreatedAccessKey + require.NoError(t, json.Unmarshal(created.Body.Bytes(), &ck)) + + t.Run("found", func(t *testing.T) { + rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-1/access-keys/"+ck.AccessKeyID, nil) + require.Equal(t, http.StatusOK, rec.Code) + require.NotContains(t, rec.Body.String(), "secretAccessKey") + var ak api.AccessKey + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &ak)) + require.Equal(t, ck.AccessKeyID, ak.AccessKeyID) + require.Equal(t, []string{"bucket-a"}, ak.Buckets) + }) + + t.Run("unknown key is 404", func(t *testing.T) { + rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-1/access-keys/z6MkUnknownKeyIdentifier", nil) + require.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("key owned by another tenant is 404", func(t *testing.T) { + rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-2/access-keys/"+ck.AccessKeyID, nil) + require.Equal(t, http.StatusNotFound, rec.Code) + }) +} + +func TestDeleteAccessKeyHandler(t *testing.T) { + ctx := t.Context() + + t.Run("deletes key, vault entry, and delegations; idempotent", func(t *testing.T) { + e, deps := setupAccessKeys(t) + created := createAccessKey(t, e, "tenant-1", api.CreateAccessKeyRequest{Name: "d", Permissions: []string{"s3:GetObject"}, Buckets: []string{"bucket-a"}}) + require.Equal(t, http.StatusCreated, created.Code) + var ck api.CreatedAccessKey + require.NoError(t, json.Unmarshal(created.Body.Bytes(), &ck)) + akDID, err := did.Parse(did.KeyPrefix + ck.AccessKeyID) + require.NoError(t, err) + + rec := doRequest(t, e, http.MethodDelete, "/tenants/tenant-1/access-keys/"+ck.AccessKeyID, nil) + require.Equal(t, http.StatusNoContent, rec.Code) + + _, err = deps.accessKeys.Get(ctx, akDID) + require.ErrorIs(t, err, store.ErrRecordNotFound) + _, err = deps.vault.Read(ctx, "/tenant/"+deps.tenantID.String()+"/access/"+akDID.String()) + require.ErrorIs(t, err, vault.ErrNotFound) + dels, err := deps.delegations.ListByAudience(ctx, akDID) + require.NoError(t, err) + require.Empty(t, dels.Results) + + again := doRequest(t, e, http.MethodDelete, "/tenants/tenant-1/access-keys/"+ck.AccessKeyID, nil) + require.Equal(t, http.StatusNotFound, again.Code) + }) + + t.Run("unknown tenant is 404", func(t *testing.T) { + e, _ := setupAccessKeys(t) + rec := doRequest(t, e, http.MethodDelete, "/tenants/missing/access-keys/z6MkWhatever", nil) + require.Equal(t, http.StatusNotFound, rec.Code) + }) +} diff --git a/pkg/api/permissions.go b/pkg/api/permissions.go new file mode 100644 index 0000000..a695a9d --- /dev/null +++ b/pkg/api/permissions.go @@ -0,0 +1,61 @@ +package api + +import ( + "github.com/fil-forge/libforge/commands/blob" + "github.com/fil-forge/libforge/commands/content" + "github.com/fil-forge/libforge/commands/index" + "github.com/fil-forge/libforge/commands/upload" + "github.com/fil-forge/ucantone/ucan" +) + +// Forge command sets, sourced from the libforge bound command types so the +// command identifiers stay in sync with their definitions. +var ( + cmdsRetrieve = []ucan.Command{content.Retrieve.Command} + cmdsAdd = []ucan.Command{blob.Add.Command, index.Add.Command, upload.Add.Command} + cmdsRemove = []ucan.Command{blob.Remove.Command, upload.Remove.Command} +) + +// s3PermissionCommands maps each supported S3 permission to the Forge commands +// that must be delegated from the tenant to the access key for it. Permissions +// with no Forge equivalent (bucket-level actions) map to nil — they are valid +// and stored on the access key, but issue no delegation and are enforced +// directly by Ingot/Hilt (see the RFC). +var s3PermissionCommands = map[string][]ucan.Command{ + "s3:GetObject": cmdsRetrieve, + "s3:GetObjectVersion": cmdsRetrieve, + "s3:GetObjectRetention": cmdsRetrieve, + "s3:GetObjectLegalHold": cmdsRetrieve, + "s3:ListBucket": cmdsRetrieve, + "s3:ListBucketVersions": cmdsRetrieve, + "s3:PutObject": cmdsAdd, + "s3:PutObjectRetention": cmdsAdd, + "s3:PutObjectLegalHold": cmdsAdd, + "s3:DeleteObject": cmdsRemove, + "s3:DeleteObjectVersion": cmdsRemove, + "s3:CreateBucket": nil, + "s3:ListAllMyBuckets": nil, + "s3:DeleteBucket": nil, +} + +// validS3Permission reports whether p is a recognized S3 permission. +func validS3Permission(p string) bool { + _, ok := s3PermissionCommands[p] + return ok +} + +// commandsForPermissions returns the deduplicated set of Forge commands to +// delegate for the given S3 permissions, preserving first-seen order. +func commandsForPermissions(permissions []string) []ucan.Command { + seen := map[string]bool{} + var cmds []ucan.Command + for _, p := range permissions { + for _, c := range s3PermissionCommands[p] { + if k := c.String(); !seen[k] { + seen[k] = true + cmds = append(cmds, c) + } + } + } + return cmds +} diff --git a/pkg/api/route.go b/pkg/api/route.go index ff5a84a..e0d1f85 100644 --- a/pkg/api/route.go +++ b/pkg/api/route.go @@ -4,10 +4,7 @@ package api import ( - "net/http" - "github.com/labstack/echo/v4" - "go.uber.org/zap" ) // Route maps an HTTP method and path to the echo handler that serves it. A @@ -23,12 +20,3 @@ type Route struct { func NewRoute(method, path string, handler echo.HandlerFunc) Route { return Route{Method: method, Path: path, Handler: handler} } - -// notImplemented returns a handler that logs and responds 501. Used by the -// current stub handlers until the endpoints are implemented. -func notImplemented(logger *zap.Logger, name string) echo.HandlerFunc { - return func(c echo.Context) error { - logger.Debug("handler not implemented", zap.String("handler", name)) - return echo.NewHTTPError(http.StatusNotImplemented, "not implemented") - } -} diff --git a/pkg/api/tenants.go b/pkg/api/tenants.go index e1dd3e4..f59137b 100644 --- a/pkg/api/tenants.go +++ b/pkg/api/tenants.go @@ -1,31 +1,346 @@ package api import ( + "context" + "errors" + "fmt" "net/http" + "github.com/fil-forge/hilt/pkg/store" + "github.com/fil-forge/hilt/pkg/store/accesskey" + "github.com/fil-forge/hilt/pkg/store/bucket" + "github.com/fil-forge/hilt/pkg/store/delegation" + "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, + secrets 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(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 := vaultTenantKeyPath(tenantID) + if err := secrets.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)) + if err := secrets.Delete(ctx, vaultKey); err != nil { + log.Error("cleaning up orphaned key", zap.Error(err)) + } + return echo.NewHTTPError(http.StatusBadGateway, "failed to register tenant DID") + } + + // Record the tenant. + if err := tenants.Add(ctx, tenantID, externalID, prov.ID, req.DisplayName, tenant.Active); err != nil { + // The tenant was not recorded; clean up its now-orphaned key. + if derr := secrets.Delete(ctx, vaultKey); derr != nil { + log.Error("cleaning up orphaned key", zap.Error(derr)) + } + // Concurrent create with the same external id: return the winner. + if errors.Is(err, store.ErrRecordExists) { + if rec, gerr := tenants.GetByExternalID(ctx, externalID); gerr == nil { + return c.JSON(http.StatusOK, tenantResponse(rec)) + } + } + 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 // operational state and quotas. -func NewGetTenantHandler(logger *zap.Logger) Route { - return NewRoute(http.MethodGet, "/tenants/:tenantId", notImplemented(logger, "GetTenant")) +func NewGetTenantHandler(logger *zap.Logger, tenants tenant.Store) Route { + log := logger.With(zap.String("handler", "GetTenant")) + return NewRoute(http.MethodGet, "/tenants/:tenantId", func(c echo.Context) error { + rec, err := tenants.GetByExternalID(c.Request().Context(), c.Param("tenantId")) + if errors.Is(err, store.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } else if err != nil { + log.Error("looking up tenant", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + return c.JSON(http.StatusOK, tenantResponse(rec)) + }) +} + +// NewUpdateTenantStatusHandler handles POST /tenants/{tenantId}/status — update +// tenant access mode. +func NewUpdateTenantStatusHandler(logger *zap.Logger, tenants tenant.Store) Route { + log := logger.With(zap.String("handler", "UpdateTenantStatus")) + return NewRoute(http.MethodPost, "/tenants/:tenantId/status", func(c echo.Context) error { + ctx := c.Request().Context() + + var req UpdateTenantStatusRequest + if err := c.Bind(&req); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "invalid request body") + } + if !validTenantStatus(req.Status) { + return echo.NewHTTPError(http.StatusUnprocessableEntity, "invalid status") + } + + rec, err := tenants.GetByExternalID(ctx, c.Param("tenantId")) + if errors.Is(err, store.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } else if err != nil { + log.Error("looking up tenant", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + if err := tenants.SetStatus(ctx, rec.ID, tenant.Status(req.Status)); err != nil { + if errors.Is(err, store.ErrRecordNotFound) { + return echo.NewHTTPError(http.StatusNotFound, "tenant not found") + } + log.Error("updating tenant status", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + return c.NoContent(http.StatusNoContent) + }) } // NewDeleteTenantHandler handles DELETE /tenants/{tenantId} — permanently -// delete a tenant (must be disabled first). -func NewDeleteTenantHandler(logger *zap.Logger) Route { - return NewRoute(http.MethodDelete, "/tenants/:tenantId", notImplemented(logger, "DeleteTenant")) +// delete a tenant (must be disabled first), cascading to its buckets, access +// keys, and delegations, and deactivating the tenant's did:plc. Idempotent. +// +// Out of scope: deprovisioning the tenant's spaces from the Forge upload +// service (Sprue), for which there is no facility per the RFC. +func NewDeleteTenantHandler( + logger *zap.Logger, + tenants tenant.Store, + buckets bucket.Store, + accessKeys accesskey.Store, + delegations delegation.Store, + secrets vault.Vault, + plcClient *plc.DirectoryClient, +) Route { + log := logger.With(zap.String("handler", "DeleteTenant")) + return NewRoute(http.MethodDelete, "/tenants/:tenantId", func(c echo.Context) error { + ctx := c.Request().Context() + + rec, err := tenants.GetByExternalID(ctx, c.Param("tenantId")) + if errors.Is(err, store.ErrRecordNotFound) { + return c.NoContent(http.StatusNoContent) // idempotent + } else if err != nil { + log.Error("looking up tenant", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + log := log.With(zap.String("external_id", rec.ExternalID), zap.Stringer("tenant", rec.ID)) + + if rec.Status != tenant.Disabled { + return echo.NewHTTPError(http.StatusConflict, "tenant must be disabled before deletion") + } + + tenantKey := vaultTenantKeyPath(rec.ID) + + // Deactivate the did:plc first — it requires the (still-present) tenant + // key. Aborting here leaves all local state intact for a retry. + if err := deactivateTenantDID(ctx, plcClient, secrets, tenantKey, rec.ID); err != nil { + log.Error("deactivating tenant DID", zap.Error(err)) + return echo.NewHTTPError(http.StatusBadGateway, "failed to deactivate tenant DID") + } + + // Cascade: access keys (records + their delegations + vault keys). + keys, err := accessKeys.ListByTenant(ctx, rec.ID) + if err != nil { + log.Error("listing access keys", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + for _, ak := range keys { + if err := delegations.DeleteByAudience(ctx, ak.ID); err != nil { + log.Error("deleting access key delegations", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + if err := secrets.Delete(ctx, vaultAccessKeyPath(rec.ID, ak.ID)); err != nil { + log.Warn("removing access key from vault", zap.Error(err)) + } + if err := accessKeys.Delete(ctx, ak.ID); err != nil { + log.Error("deleting access key", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + } + + // Cascade: buckets (records; bucket keys are discarded at creation). + bucketIDs, err := store.Collect(ctx, func(ctx context.Context, opts store.PaginationConfig) (store.Page[did.DID], error) { + var listOpts []bucket.ListOption + if opts.Cursor != nil { + listOpts = append(listOpts, bucket.WithCursor(*opts.Cursor)) + } + page, err := buckets.ListByTenant(ctx, rec.ID, listOpts...) + if err != nil { + return store.Page[did.DID]{}, err + } + ids := make([]did.DID, 0, len(page.Results)) + for _, r := range page.Results { + ids = append(ids, r.ID) + } + return store.Page[did.DID]{Results: ids, Cursor: page.Cursor}, nil + }) + if err != nil { + log.Error("listing buckets", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + for _, id := range bucketIDs { + if err := buckets.Delete(ctx, id); err != nil { + log.Error("deleting bucket", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + } + + // Delegations addressed to the tenant (the bucket -> tenant grants). + if err := delegations.DeleteByAudience(ctx, rec.ID); err != nil { + log.Error("deleting tenant delegations", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + + if err := tenants.Delete(ctx, rec.ID); err != nil { + log.Error("deleting tenant", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + // Best-effort removal of the tenant's key material. + if err := secrets.Delete(ctx, tenantKey); err != nil { + log.Warn("removing tenant key from vault", zap.Error(err)) + } + log.Info("deleted tenant") + return c.NoContent(http.StatusNoContent) + }) } -// NewUpdateTenantStatusHandler handles POST /tenants/{tenantId}/status — update -// tenant access mode. -func NewUpdateTenantStatusHandler(logger *zap.Logger) Route { - return NewRoute(http.MethodPost, "/tenants/:tenantId/status", notImplemented(logger, "UpdateTenantStatus")) +// deactivateTenantDID publishes a tombstone for the tenant's did:plc, signed +// with its rotation key from the vault. If the DID is already deactivated it is +// a no-op. +func deactivateTenantDID(ctx context.Context, plcClient *plc.DirectoryClient, v vault.Vault, vaultKey string, tenantDID did.DID) error { + last, err := plcClient.Last(ctx, tenantDID) + if err != nil { + if _, ok := errors.AsType[*plc.DeactivatedDIDError](err); ok { + return nil // already deactivated + } + return fmt.Errorf("fetching last operation: %w", err) + } + + keyBytes, err := v.Read(ctx, vaultKey) + if err != nil { + return fmt.Errorf("reading tenant key: %w", err) + } + signer, err := secp256k1.Decode(keyBytes) + if err != nil { + return fmt.Errorf("decoding tenant key: %w", err) + } + + tomb, err := plc.NewTombstoneFromPrevious(last) + if err != nil { + return fmt.Errorf("building tombstone: %w", err) + } + signed, err := plc.SignTombstone(signer, tomb) + if err != nil { + return fmt.Errorf("signing tombstone: %w", err) + } + if err := plcClient.Deactivate(ctx, tenantDID, signed); err != nil { + return fmt.Errorf("publishing tombstone: %w", err) + } + return nil +} + +// validTenantStatus reports whether s is a recognized tenant status. +func validTenantStatus(s TenantStatus) bool { + switch s { + case TenantStatusActive, TenantStatusWriteLocked, TenantStatusDisabled: + return true + default: + return false + } } diff --git a/pkg/api/tenants_test.go b/pkg/api/tenants_test.go new file mode 100644 index 0000000..2c60d4b --- /dev/null +++ b/pkg/api/tenants_test.go @@ -0,0 +1,520 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" + + "github.com/fil-forge/hilt/internal/testutil" + "github.com/fil-forge/hilt/pkg/api" + "github.com/fil-forge/hilt/pkg/store" + accesskeymemory "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" + delegationmemory "github.com/fil-forge/hilt/pkg/store/delegation/memory" + "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" + "github.com/fil-forge/ucantone/did/plc" + "github.com/fil-forge/ucantone/multikey/secp256k1" + "github.com/fil-forge/ucantone/ucan" + "github.com/fil-forge/ucantone/ucan/command" + "github.com/fil-forge/ucantone/ucan/delegation" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// spyVault wraps a vault and tracks which keys are currently live (written and +// not yet deleted) plus how many writes occurred, so tests can assert that a key +// was written and subsequently cleaned up. +type spyVault struct { + vault.Vault + mu sync.Mutex + live map[string]bool + writes int +} + +func newSpyVault() *spyVault { + return &spyVault{Vault: vaultmemory.New(), live: map[string]bool{}} +} + +func (s *spyVault) Write(ctx context.Context, key string, value []byte) error { + s.mu.Lock() + s.live[key] = true + s.writes++ + s.mu.Unlock() + return s.Vault.Write(ctx, key, value) +} + +func (s *spyVault) Delete(ctx context.Context, key string) error { + s.mu.Lock() + delete(s.live, key) + s.mu.Unlock() + return s.Vault.Delete(ctx, key) +} + +func (s *spyVault) liveKeys() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.live) +} + +// addFailTenantStore is a tenant.Store whose Add always fails. It embeds a real +// memory store so the handler's idempotency lookup (GetByExternalID) behaves. +type addFailTenantStore struct { + tenant.Store + err error +} + +func (s addFailTenantStore) Add(context.Context, did.DID, string, did.DID, string, tenant.Status) error { + return s.err +} + +// provisionServer wires the provision handler to the given stores/vault and a PLC +// directory client pointed at an httptest server that returns plcStatus. +func provisionServer(t *testing.T, tenants tenant.Store, providers provider.Store, secrets vault.Vault, plcStatus int) *echo.Echo { + t.Helper() + plcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(plcStatus) + })) + 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(), tenants, providers, secrets, plcClient) + e := echo.New() + e.Add(route.Method, route.Path, route.Handler) + return e +} + +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) + })) + 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) + }) + + t.Run("cleans up the orphaned key when PLC publication fails", func(t *testing.T) { + tenants := tenantmemory.New() + providers := providermemory.New() + require.NoError(t, providers.Add(ctx, testutil.RandomDID(t), "us-east-1")) + secrets := newSpyVault() + + e := provisionServer(t, tenants, providers, secrets, http.StatusInternalServerError) + rec := provisionRequest(t, e, "tenant-6", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"}) + require.Equal(t, http.StatusBadGateway, rec.Code) + + // A key was written then cleaned up, and no tenant was recorded. + require.Positive(t, secrets.writes) + require.Zero(t, secrets.liveKeys()) + _, err := tenants.GetByExternalID(ctx, "tenant-6") + require.ErrorIs(t, err, store.ErrRecordNotFound) + }) + + t.Run("cleans up the orphaned key when storing the tenant fails", func(t *testing.T) { + tenants := addFailTenantStore{Store: tenantmemory.New(), err: errors.New("boom")} + providers := providermemory.New() + require.NoError(t, providers.Add(ctx, testutil.RandomDID(t), "us-east-1")) + secrets := newSpyVault() + + e := provisionServer(t, tenants, providers, secrets, http.StatusOK) + rec := provisionRequest(t, e, "tenant-7", api.ProvisionTenantRequest{DisplayName: "Acme", Region: "us-east-1"}) + require.Equal(t, http.StatusInternalServerError, rec.Code) + + // The key written before the failed Add was cleaned up. + require.Positive(t, secrets.writes) + require.Zero(t, secrets.liveKeys()) + }) +} + +// serve wraps a single Route in an echo server. +func serve(route api.Route) *echo.Echo { + e := echo.New() + e.Add(route.Method, route.Path, route.Handler) + return e +} + +// doRequest issues an HTTP request against e. A non-empty body is sent as JSON. +func doRequest(t *testing.T, e *echo.Echo, method, target string, body []byte) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, target, bytes.NewReader(body)) + if len(body) > 0 { + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func TestGetTenantHandler(t *testing.T) { + ctx := t.Context() + tenants := tenantmemory.New() + require.NoError(t, tenants.Add(ctx, testutil.RandomDID(t), "tenant-1", testutil.RandomDID(t), "Acme", tenant.Active)) + e := serve(api.NewGetTenantHandler(zap.NewNop(), tenants)) + + t.Run("found", func(t *testing.T) { + rec := doRequest(t, e, http.MethodGet, "/tenants/tenant-1", nil) + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Body.String(), `"tenantId":"tenant-1"`) + require.Contains(t, rec.Body.String(), `"status":"active"`) + }) + + t.Run("not found", func(t *testing.T) { + rec := doRequest(t, e, http.MethodGet, "/tenants/missing", nil) + require.Equal(t, http.StatusNotFound, rec.Code) + }) +} + +func TestUpdateTenantStatusHandler(t *testing.T) { + ctx := t.Context() + tenants := tenantmemory.New() + id := testutil.RandomDID(t) + require.NoError(t, tenants.Add(ctx, id, "tenant-1", testutil.RandomDID(t), "Acme", tenant.Active)) + e := serve(api.NewUpdateTenantStatusHandler(zap.NewNop(), tenants)) + + statusBody := func(s api.TenantStatus) []byte { + b, err := json.Marshal(api.UpdateTenantStatusRequest{Status: s}) + require.NoError(t, err) + return b + } + + t.Run("updates status", func(t *testing.T) { + rec := doRequest(t, e, http.MethodPost, "/tenants/tenant-1/status", statusBody(api.TenantStatusWriteLocked)) + require.Equal(t, http.StatusNoContent, rec.Code) + + got, err := tenants.Get(ctx, id) + require.NoError(t, err) + require.Equal(t, tenant.WriteLocked, got.Status) + }) + + t.Run("unknown tenant", func(t *testing.T) { + rec := doRequest(t, e, http.MethodPost, "/tenants/missing/status", statusBody(api.TenantStatusDisabled)) + require.Equal(t, http.StatusNotFound, rec.Code) + }) + + t.Run("invalid status value", func(t *testing.T) { + rec := doRequest(t, e, http.MethodPost, "/tenants/tenant-1/status", []byte(`{"status":"bogus"}`)) + require.Equal(t, http.StatusUnprocessableEntity, rec.Code) + }) + + t.Run("missing status", func(t *testing.T) { + rec := doRequest(t, e, http.MethodPost, "/tenants/tenant-1/status", []byte(`{}`)) + require.Equal(t, http.StatusUnprocessableEntity, rec.Code) + }) +} + +// plcDirectory is an httptest-backed did:plc directory for the delete tests. It +// serves the tenant's last operation (a genesis op by default, or a tombstone to +// simulate an already-deactivated DID) at GET .../log/last, and accepts the +// tombstone publish at POST .../{did}. The handler talks to it through a real +// *plc.DirectoryClient, exercising the DagJSON decode path over an httptest body. +type plcDirectory struct { + server *httptest.Server + logLast []byte // DagJSON served at GET .../log/last + logLastStatus int // overrides the 200 status when non-zero (to simulate failures) + deactivations int // count of tombstone POSTs +} + +func (d *plcDirectory) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: // .../log/last + if d.logLastStatus != 0 { + w.WriteHeader(d.logLastStatus) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(d.logLast) + case http.MethodPost: // tombstone publish + d.deactivations++ + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +type deleteDeps struct { + tenants *tenantmemory.Store + buckets *bucketmemory.Store + accessKeys *accesskeymemory.Store + delegations *delegationmemory.Store + vault vault.Vault + directory *plcDirectory + signer secp256k1.Signer + genesis *plc.SignedOperation + tenantID did.DID +} + +// serveTombstone makes the directory report the tenant DID as already +// deactivated by serving a signed tombstone as its last operation. +func (d *deleteDeps) serveTombstone(t *testing.T) { + t.Helper() + tomb, err := plc.NewTombstoneFromPrevious(d.genesis) + require.NoError(t, err) + signed, err := plc.SignTombstone(d.signer, tomb) + require.NoError(t, err) + var buf bytes.Buffer + require.NoError(t, signed.MarshalDagJSON(&buf)) + d.directory.logLast = buf.Bytes() +} + +// setupDelete builds a delete handler over memory stores and an httptest did:plc +// directory. The tenant is created with a real did:plc whose genesis op the +// directory serves, so the handler can fetch it and sign a tombstone. +func setupDelete(t *testing.T, status tenant.Status) (*echo.Echo, *deleteDeps) { + t.Helper() + ctx := t.Context() + + signer, err := secp256k1.Generate() + require.NoError(t, err) + key := signer.KeyDID() + tenantID, genesis, err := plc.New(signer, + plc.WithRotationKeys(key), + plc.WithVerificationMethods(map[string]did.DID{"hilt": key}), + ) + require.NoError(t, err) + + var genesisJSON bytes.Buffer + require.NoError(t, genesis.MarshalDagJSON(&genesisJSON)) + + directory := &plcDirectory{logLast: genesisJSON.Bytes()} + directory.server = httptest.NewServer(directory) + t.Cleanup(directory.server.Close) + + endpoint, err := url.Parse(directory.server.URL) + require.NoError(t, err) + plcClient, err := plc.NewDirectoryClient(*endpoint) + require.NoError(t, err) + + deps := &deleteDeps{ + tenants: tenantmemory.New(), + buckets: bucketmemory.New(), + accessKeys: accesskeymemory.New(), + delegations: delegationmemory.New(), + vault: vaultmemory.New(), + directory: directory, + signer: signer, + genesis: genesis, + tenantID: tenantID, + } + require.NoError(t, deps.tenants.Add(ctx, tenantID, "tenant-1", testutil.RandomDID(t), "Acme", status)) + require.NoError(t, deps.vault.Write(ctx, "/tenant/"+tenantID.String(), signer.Bytes())) + + route := api.NewDeleteTenantHandler(zap.NewNop(), deps.tenants, deps.buckets, deps.accessKeys, deps.delegations, deps.vault, plcClient) + return serve(route), deps +} + +func makeDelegation(t *testing.T, audience did.DID) ucan.Delegation { + t.Helper() + issuer := testutil.RandomIssuer(t) + d, err := delegation.Delegate(issuer, audience, issuer.DID(), command.MustParse("/test/run")) + require.NoError(t, err) + return d +} + +func TestDeleteTenantHandler(t *testing.T) { + ctx := t.Context() + + t.Run("deletes a disabled tenant and cascades", func(t *testing.T) { + e, deps := setupDelete(t, tenant.Disabled) + + // Seed owned resources: a bucket, an access key (+ vault key), and + // delegations addressed to the tenant and to the access key. + bucketID := testutil.RandomDID(t) + require.NoError(t, deps.buckets.Add(ctx, bucketID, deps.tenantID, "b1")) + akID := testutil.RandomDID(t) + require.NoError(t, deps.accessKeys.Add(ctx, akID, deps.tenantID, "k1", nil, []string{"s3:GetObject"}, nil)) + akVaultKey := "/tenant/" + deps.tenantID.String() + "/access/" + akID.String() + require.NoError(t, deps.vault.Write(ctx, akVaultKey, []byte("ak-key"))) + require.NoError(t, deps.delegations.PutBatch(ctx, []ucan.Delegation{makeDelegation(t, deps.tenantID)})) + require.NoError(t, deps.delegations.PutBatch(ctx, []ucan.Delegation{makeDelegation(t, akID)})) + + rec := doRequest(t, e, http.MethodDelete, "/tenants/tenant-1", nil) + require.Equal(t, http.StatusNoContent, rec.Code) + + // Tenant + its key gone. + _, err := deps.tenants.GetByExternalID(ctx, "tenant-1") + require.ErrorIs(t, err, store.ErrRecordNotFound) + _, err = deps.vault.Read(ctx, "/tenant/"+deps.tenantID.String()) + require.ErrorIs(t, err, vault.ErrNotFound) + + // Buckets + access keys (records and vault key) gone. + bs, err := deps.buckets.ListByTenant(ctx, deps.tenantID) + require.NoError(t, err) + require.Empty(t, bs.Results) + aks, err := deps.accessKeys.ListByTenant(ctx, deps.tenantID) + require.NoError(t, err) + require.Empty(t, aks) + _, err = deps.vault.Read(ctx, akVaultKey) + require.ErrorIs(t, err, vault.ErrNotFound) + + // Delegations to both the tenant and the access key gone. + tenantDlgs, err := deps.delegations.ListByAudience(ctx, deps.tenantID) + require.NoError(t, err) + require.Empty(t, tenantDlgs.Results) + akDlgs, err := deps.delegations.ListByAudience(ctx, akID) + require.NoError(t, err) + require.Empty(t, akDlgs.Results) + + // The DID was deactivated in the directory. + require.Equal(t, 1, deps.directory.deactivations) + }) + + t.Run("already-deactivated DID still cleans up locally", func(t *testing.T) { + e, deps := setupDelete(t, tenant.Disabled) + deps.serveTombstone(t) // directory reports the DID as already tombstoned + + rec := doRequest(t, e, http.MethodDelete, "/tenants/tenant-1", nil) + require.Equal(t, http.StatusNoContent, rec.Code) + + _, err := deps.tenants.GetByExternalID(ctx, "tenant-1") + require.ErrorIs(t, err, store.ErrRecordNotFound) + require.Equal(t, 0, deps.directory.deactivations) // no second tombstone published + }) + + t.Run("rejects a non-disabled tenant", func(t *testing.T) { + e, deps := setupDelete(t, tenant.Active) + rec := doRequest(t, e, http.MethodDelete, "/tenants/tenant-1", nil) + require.Equal(t, http.StatusConflict, rec.Code) + + _, err := deps.tenants.GetByExternalID(ctx, "tenant-1") + require.NoError(t, err) + require.Equal(t, 0, deps.directory.deactivations) + }) + + t.Run("unknown tenant is idempotent", func(t *testing.T) { + e, deps := setupDelete(t, tenant.Disabled) + rec := doRequest(t, e, http.MethodDelete, "/tenants/missing", nil) + require.Equal(t, http.StatusNoContent, rec.Code) + require.Equal(t, 0, deps.directory.deactivations) + }) + + t.Run("aborts when the directory is unreachable", func(t *testing.T) { + e, deps := setupDelete(t, tenant.Disabled) + deps.directory.logLastStatus = http.StatusInternalServerError + + rec := doRequest(t, e, http.MethodDelete, "/tenants/tenant-1", nil) + require.Equal(t, http.StatusBadGateway, rec.Code) + + // Nothing was deleted; the operation is retryable. + _, err := deps.tenants.GetByExternalID(ctx, "tenant-1") + require.NoError(t, err) + }) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index ff3d1dd..ef38cfd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -30,11 +30,25 @@ const ( // Config holds the hilt service configuration. type Config struct { - Server ServerConfig `mapstructure:"server"` - Log LogConfig `mapstructure:"log"` - Storage StorageConfig `mapstructure:"storage"` - Vault VaultConfig `mapstructure:"vault"` - Auth AuthConfig `mapstructure:"auth"` + Identity IdentityConfig `mapstructure:"identity"` + Server ServerConfig `mapstructure:"server"` + Log LogConfig `mapstructure:"log"` + Storage StorageConfig `mapstructure:"storage"` + Vault VaultConfig `mapstructure:"vault"` + PLC PLCConfig `mapstructure:"plc"` + Auth AuthConfig `mapstructure:"auth"` +} + +// IdentityConfig holds the Hilt service identity used to sign and receive UCAN +// invocations on the UCAN RPC API. +type IdentityConfig struct { + // KeyFile is the path to a PEM-encoded Ed25519 private key. When empty, an + // ephemeral key is generated at startup (its DID changes each restart). + KeyFile string `mapstructure:"key_file"` + // ServiceID is an optional did:web identity to wrap the key with, allowing + // the service to accept UCANs addressed to the did:web (e.g. + // "did:web:hilt.example.com"). When empty, the key's did:key is used. + ServiceID string `mapstructure:"service_id"` } // ServerConfig holds HTTP server settings. @@ -54,6 +68,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". @@ -122,6 +143,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. @@ -136,6 +159,8 @@ func BindEnvVars(v *viper.Viper) { // defaults. Flags that are absent from the set are skipped. func BindFlags(v *viper.Viper, flags *pflag.FlagSet) error { bindings := map[string]string{ + "identity.key_file": "identity-key-file", + "identity.service_id": "identity-service-id", "server.host": "host", "server.port": "port", "storage.type": "storage", @@ -149,6 +174,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 { diff --git a/pkg/fx/app.go b/pkg/fx/app.go index 2335a3e..c4711f8 100644 --- a/pkg/fx/app.go +++ b/pkg/fx/app.go @@ -18,7 +18,10 @@ func AppModule(cfg *config.Config) fx.Option { fx.Supply(cfg), ConfigModule, LoggerModule, + IdentityModule, + PLCModule, APIModule, + RPCModule, ServerModule, } diff --git a/pkg/fx/config.go b/pkg/fx/config.go index d7b20ac..3812604 100644 --- a/pkg/fx/config.go +++ b/pkg/fx/config.go @@ -14,24 +14,28 @@ var ConfigModule = fx.Module("config", // Configs exposes the individual fields of the config to the fx graph. type Configs struct { fx.Out - Server config.ServerConfig - Log config.LogConfig + Identity config.IdentityConfig + Server config.ServerConfig + Log config.LogConfig Storage config.StorageConfig Postgres config.PostgresConfig Vault config.VaultConfig Hashicorp config.HashicorpConfig + PLC config.PLCConfig Auth config.AuthConfig } // ProvideConfigs provides the individual fields of the config. func ProvideConfigs(cfg *config.Config) Configs { return Configs{ + Identity: cfg.Identity, Server: cfg.Server, Log: cfg.Log, Storage: cfg.Storage, Postgres: cfg.Storage.Postgres, Vault: cfg.Vault, Hashicorp: cfg.Vault.Hashicorp, + PLC: cfg.PLC, Auth: cfg.Auth, } } diff --git a/pkg/fx/identity.go b/pkg/fx/identity.go new file mode 100644 index 0000000..df0bfeb --- /dev/null +++ b/pkg/fx/identity.go @@ -0,0 +1,42 @@ +package fx + +import ( + "fmt" + + "github.com/fil-forge/hilt/pkg/config" + "github.com/fil-forge/libforge/identity" + "go.uber.org/fx" + "go.uber.org/zap" +) + +// IdentityModule provides the Hilt service identity used by the UCAN RPC server. +var IdentityModule = fx.Module("identity", + fx.Provide(NewIdentity), +) + +// NewIdentity builds the service identity from configuration. With a key file +// it loads the PEM-encoded Ed25519 key (wrapping it with the configured did:web +// when set); otherwise it generates an ephemeral identity (whose DID changes +// each restart). +func NewIdentity(cfg config.IdentityConfig, logger *zap.Logger) (identity.Identity, error) { + if cfg.KeyFile == "" { + id, err := identity.New("", cfg.ServiceID) + if err != nil { + return identity.Identity{}, fmt.Errorf("creating ephemeral identity: %w", err) + } + logger.Warn("no identity.key_file configured; generated an ephemeral identity (DID changes each restart)", + zap.Stringer("id", id.DID()), + ) + return id, nil + } + + id, err := identity.NewFromPEMFileWithDID(cfg.KeyFile, cfg.ServiceID) + if err != nil { + return identity.Identity{}, fmt.Errorf("loading identity from key file: %w", err) + } + logger.Info("loaded service identity from PEM", + zap.Stringer("id", id.DID()), + zap.String("key_file", cfg.KeyFile), + ) + return id, nil +} diff --git a/pkg/fx/plc.go b/pkg/fx/plc.go new file mode 100644 index 0000000..ba20ce0 --- /dev/null +++ b/pkg/fx/plc.go @@ -0,0 +1,28 @@ +package fx + +import ( + "fmt" + "net/url" + "time" + + "github.com/fil-forge/hilt/pkg/config" + "github.com/fil-forge/ucantone/did/plc" + "go.uber.org/fx" +) + +// PLCModule provides a did:plc directory client for the tenant handlers. +var PLCModule = fx.Module("plc", + fx.Provide(NewPLCClient), +) + +// NewPLCClient builds a did:plc directory client from configuration. +func NewPLCClient(cfg config.PLCConfig) (*plc.DirectoryClient, error) { + if cfg.Directory == "" { + return nil, fmt.Errorf("plc.directory is required") + } + u, err := url.Parse(cfg.Directory) + if err != nil { + return nil, fmt.Errorf("parsing plc.directory %q: %w", cfg.Directory, err) + } + return plc.NewDirectoryClient(*u, plc.WithTimeout(time.Second*10)) +} diff --git a/pkg/fx/plc_test.go b/pkg/fx/plc_test.go new file mode 100644 index 0000000..b8fa865 --- /dev/null +++ b/pkg/fx/plc_test.go @@ -0,0 +1,27 @@ +package fx_test + +import ( + "testing" + + "github.com/fil-forge/hilt/pkg/config" + appfx "github.com/fil-forge/hilt/pkg/fx" + "github.com/stretchr/testify/require" +) + +func TestNewPLCClient(t *testing.T) { + t.Run("valid endpoint", func(t *testing.T) { + client, err := appfx.NewPLCClient(config.PLCConfig{Directory: "https://plc.directory"}) + require.NoError(t, err) + require.NotNil(t, client) + }) + + t.Run("empty endpoint errors", func(t *testing.T) { + _, err := appfx.NewPLCClient(config.PLCConfig{Directory: ""}) + require.Error(t, err) + }) + + t.Run("unparseable endpoint errors", func(t *testing.T) { + _, err := appfx.NewPLCClient(config.PLCConfig{Directory: "://nope"}) + require.Error(t, err) + }) +} diff --git a/pkg/fx/rpc.go b/pkg/fx/rpc.go new file mode 100644 index 0000000..abf4f36 --- /dev/null +++ b/pkg/fx/rpc.go @@ -0,0 +1,46 @@ +package fx + +import ( + "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/libforge/identity" + "github.com/fil-forge/ucantone/server" + "go.uber.org/fx" +) + +// RPCModule provides the Hilt UCAN RPC server and the command handlers it +// serves, collected into the "ucanRoutes" group. +var RPCModule = fx.Module("rpc", + fx.Provide( + NewUCANServer, + asUCANRoute(rpc.NewAuthorizeRequestHandler), + asUCANRoute(rpc.NewCreateBucketHandler), + asUCANRoute(rpc.NewDeleteBucketHandler), + asUCANRoute(rpc.NewBucketInfoHandler), + asUCANRoute(rpc.NewListBucketsHandler), + ), +) + +// asUCANRoute annotates a handler constructor so its result joins the +// "ucanRoutes" group consumed by the UCAN server. +func asUCANRoute(constructor any) any { + return fx.Annotate(constructor, fx.ResultTags(`group:"ucanRoutes"`)) +} + +// UCANServerParams are the dependencies for the UCAN RPC server. Handlers are +// collected from the "ucanRoutes" fx group (see RPCModule). +type UCANServerParams struct { + fx.In + Identity identity.Identity // embeds multikey.Issuer, satisfying ucan.Issuer + Routes []server.Route `group:"ucanRoutes"` +} + +// NewUCANServer builds the ucantone UCAN RPC server with the service identity +// and registers each command handler. The returned *server.HTTPServer is an +// http.Handler mounted on the echo server (see NewEchoServer). +func NewUCANServer(p UCANServerParams) *server.HTTPServer { + srv := server.NewHTTP(p.Identity) + for _, r := range p.Routes { + srv.Handle(r.Command, r.Handler) + } + return srv +} diff --git a/pkg/fx/rpc_test.go b/pkg/fx/rpc_test.go new file mode 100644 index 0000000..32da8ce --- /dev/null +++ b/pkg/fx/rpc_test.go @@ -0,0 +1,41 @@ +package fx_test + +import ( + "testing" + + "github.com/fil-forge/hilt/pkg/config" + appfx "github.com/fil-forge/hilt/pkg/fx" + "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/ucantone/server" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestNewIdentityEphemeral(t *testing.T) { + id, err := appfx.NewIdentity(config.IdentityConfig{}, zap.NewNop()) + require.NoError(t, err) + require.True(t, id.DID().Defined()) + require.Equal(t, "key", id.DID().Method()) // ephemeral key ⇒ did:key +} + +func TestNewIdentityMissingKeyFile(t *testing.T) { + _, err := appfx.NewIdentity(config.IdentityConfig{KeyFile: "/nonexistent/hilt.pem"}, zap.NewNop()) + require.Error(t, err) +} + +func TestNewUCANServer(t *testing.T) { + id, err := appfx.NewIdentity(config.IdentityConfig{}, zap.NewNop()) + require.NoError(t, err) + + srv := appfx.NewUCANServer(appfx.UCANServerParams{ + Identity: id, + Routes: []server.Route{ + rpc.NewAuthorizeRequestHandler(zap.NewNop()), + rpc.NewCreateBucketHandler(zap.NewNop()), + rpc.NewDeleteBucketHandler(zap.NewNop()), + rpc.NewBucketInfoHandler(zap.NewNop()), + rpc.NewListBucketsHandler(zap.NewNop()), + }, + }) + require.NotNil(t, srv) +} diff --git a/pkg/fx/server.go b/pkg/fx/server.go index 9cc64c4..5b2936e 100644 --- a/pkg/fx/server.go +++ b/pkg/fx/server.go @@ -9,6 +9,8 @@ import ( "github.com/fil-forge/hilt/pkg/api" "github.com/fil-forge/hilt/pkg/config" "github.com/fil-forge/hilt/pkg/echo/middleware" + "github.com/fil-forge/libforge/identity" + "github.com/fil-forge/ucantone/server" "github.com/labstack/echo/v4" echomiddleware "github.com/labstack/echo/v4/middleware" "go.uber.org/fx" @@ -25,9 +27,11 @@ var ServerModule = fx.Module("server", // are collected from the "routes" fx group (see APIModule). type ServerParams struct { fx.In - Logger *zap.Logger - Auth config.AuthConfig - Routes []api.Route `group:"routes"` + Logger *zap.Logger + Auth config.AuthConfig + Identity identity.Identity + Routes []api.Route `group:"routes"` + UCANServer *server.HTTPServer } // NewEchoServer creates and configures the Echo HTTP server. @@ -45,6 +49,12 @@ func NewEchoServer(p ServerParams) *echo.Echo { e.GET("/health", func(c echo.Context) error { return c.String(http.StatusOK, "OK") }) + // Public DID document for did:web resolution of the service identity. + e.GET("/.well-known/did.json", didDocumentHandler(p.Logger, p.Identity)) + + // UCAN RPC API (for Ingot): invocations self-authenticate via the dispatcher, + // so this stays outside the partner-key group. + e.POST("/", echo.WrapHandler(p.UCANServer)) // Tenant API routes require partner-key bearer auth; / and /health stay open. api := e.Group("", middleware.PartnerKeyAuth(p.Auth.PartnerKey, p.Logger)) @@ -95,3 +105,16 @@ func RegisterServerLifecycle( }, }) } + +// didDocumentHandler serves the service identity's DID document for did:web +// resolution, so other services can verify Hilt's UCAN signatures. +func didDocumentHandler(logger *zap.Logger, id identity.Identity) echo.HandlerFunc { + return func(c echo.Context) error { + doc, err := id.DIDDocument() + if err != nil { + logger.Error("building DID document", zap.Error(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "internal error") + } + return c.JSON(http.StatusOK, doc) + } +} diff --git a/pkg/fx/server_test.go b/pkg/fx/server_test.go new file mode 100644 index 0000000..d486b77 --- /dev/null +++ b/pkg/fx/server_test.go @@ -0,0 +1,32 @@ +package fx_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/fil-forge/hilt/pkg/config" + appfx "github.com/fil-forge/hilt/pkg/fx" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestDIDDocumentRoute(t *testing.T) { + id, err := appfx.NewIdentity(config.IdentityConfig{}, zap.NewNop()) + require.NoError(t, err) + + e := appfx.NewEchoServer(appfx.ServerParams{ + Logger: zap.NewNop(), + Identity: id, + UCANServer: appfx.NewUCANServer(appfx.UCANServerParams{Identity: id}), + }) + + // Public route: reachable without a partner-key bearer token. + req := httptest.NewRequest(http.MethodGet, "/.well-known/did.json", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Header().Get("Content-Type"), "application/json") + require.Contains(t, rec.Body.String(), id.DID().String()) +} diff --git a/pkg/migrations/sql/00001_init.sql b/pkg/migrations/sql/00001_init.sql index 27d12ed..9100100 100644 --- a/pkg/migrations/sql/00001_init.sql +++ b/pkg/migrations/sql/00001_init.sql @@ -8,7 +8,8 @@ CREATE TABLE provider ( ); CREATE TABLE tenant ( - id TEXT PRIMARY KEY, -- DID + id TEXT PRIMARY KEY, -- DID (did:plc) + external_id TEXT UNIQUE, -- external Tenant API id ({tenantId}) provider_id TEXT, -- DID name TEXT, status TEXT NOT NULL, -- active, write-locked, disabled @@ -29,7 +30,9 @@ CREATE TABLE access_key ( name TEXT, buckets TEXT[], permissions TEXT[] NOT NULL, - created_at TIMESTAMPTZ NOT NULL + created_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + UNIQUE (tenant_id, name) ); CREATE TABLE delegation ( diff --git a/pkg/rpc/rpc.go b/pkg/rpc/rpc.go new file mode 100644 index 0000000..11e8742 --- /dev/null +++ b/pkg/rpc/rpc.go @@ -0,0 +1,68 @@ +// Package rpc implements the Hilt UCAN RPC API — the S3 commands Ingot invokes +// on Hilt (see the Forge S3 tenant-management RFC). Handlers are exposed as +// [server.Route] values, collected via fx and registered on the UCAN server. +// +// The handlers are currently stubs that report "not implemented"; the bodies +// will be filled in a later pass. +package rpc + +import ( + "errors" + + s3bkt "github.com/fil-forge/libforge/commands/s3/bucket" + s3req "github.com/fil-forge/libforge/commands/s3/request" + "github.com/fil-forge/ucantone/binding" + "github.com/fil-forge/ucantone/server" + "go.uber.org/zap" +) + +var errNotImplemented = errors.New("not implemented") + +// NewAuthorizeRequestHandler handles /s3/request/authorize — authorize an AWS S3 +// API request and return the derived signing key and delegations. +func NewAuthorizeRequestHandler(logger *zap.Logger) server.Route { + log := logger.With(zap.Stringer("command", s3req.Authorize.Command)) + return s3req.Authorize.Route(func(req *binding.Request[*s3req.AuthorizeArguments], res *binding.Response[*s3req.AuthorizeOK]) error { + log.Debug("not implemented") + return errNotImplemented + }) +} + +// NewCreateBucketHandler handles /s3/bucket/create — create a bucket and +// provision it with Sprue. +func NewCreateBucketHandler(logger *zap.Logger) server.Route { + log := logger.With(zap.Stringer("command", s3bkt.Create.Command)) + return s3bkt.Create.Route(func(req *binding.Request[*s3bkt.CreateArguments], res *binding.Response[*s3req.AuthorizeOK]) error { + log.Debug("not implemented") + return errNotImplemented + }) +} + +// NewDeleteBucketHandler handles /s3/bucket/delete — delete an empty bucket and +// revoke the delegations that grant access to it. +func NewDeleteBucketHandler(logger *zap.Logger) server.Route { + log := logger.With(zap.Stringer("command", s3bkt.Delete.Command)) + return s3bkt.Delete.Route(func(req *binding.Request[*s3bkt.DeleteArguments], res *binding.Response[*s3bkt.DeleteOK]) error { + log.Debug("not implemented") + return errNotImplemented + }) +} + +// NewBucketInfoHandler handles /s3/bucket/info — return a bucket DID and the +// delegation chain to the given access key. +func NewBucketInfoHandler(logger *zap.Logger) server.Route { + log := logger.With(zap.Stringer("command", s3bkt.Info.Command)) + return s3bkt.Info.Route(func(req *binding.Request[*s3bkt.InfoArguments], res *binding.Response[*s3bkt.InfoOK]) error { + log.Debug("not implemented") + return errNotImplemented + }) +} + +// NewListBucketsHandler handles /s3/bucket/list — list the tenant's buckets. +func NewListBucketsHandler(logger *zap.Logger) server.Route { + log := logger.With(zap.Stringer("command", s3bkt.List.Command)) + return s3bkt.List.Route(func(req *binding.Request[*s3bkt.ListArguments], res *binding.Response[*s3bkt.ListOK]) error { + log.Debug("not implemented") + return errNotImplemented + }) +} diff --git a/pkg/rpc/rpc_test.go b/pkg/rpc/rpc_test.go new file mode 100644 index 0000000..7663c67 --- /dev/null +++ b/pkg/rpc/rpc_test.go @@ -0,0 +1,31 @@ +package rpc_test + +import ( + "testing" + + "github.com/fil-forge/hilt/pkg/rpc" + "github.com/fil-forge/ucantone/server" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestHandlerCommands(t *testing.T) { + cases := []struct { + name string + route func(*zap.Logger) server.Route + command string + }{ + {"authorize", rpc.NewAuthorizeRequestHandler, "/s3/request/authorize"}, + {"create", rpc.NewCreateBucketHandler, "/s3/bucket/create"}, + {"delete", rpc.NewDeleteBucketHandler, "/s3/bucket/delete"}, + {"info", rpc.NewBucketInfoHandler, "/s3/bucket/info"}, + {"list", rpc.NewListBucketsHandler, "/s3/bucket/list"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + route := tc.route(zap.NewNop()) + require.Equal(t, tc.command, route.Command.String()) + require.NotNil(t, route.Handler) + }) + } +} diff --git a/pkg/store/accesskey/accesskey.go b/pkg/store/accesskey/accesskey.go index 685cd50..0f585cd 100644 --- a/pkg/store/accesskey/accesskey.go +++ b/pkg/store/accesskey/accesskey.go @@ -21,13 +21,21 @@ type Record struct { Permissions []string // When the access key record was created. CreatedAt time.Time + // When the access key expires. Nil means it never expires. + ExpiresAt *time.Time } type Store interface { // Add creates a new access key record. It returns [store.ErrRecordExists] if - // a record with the same ID already exists. - Add(ctx context.Context, id did.DID, tenant did.DID, name string, buckets []did.DID, permissions []string) error + // a record with the same ID, or the same (tenant, name), already exists. + // Names must be unique within a tenant. + Add(ctx context.Context, id did.DID, tenant did.DID, name string, buckets []did.DID, permissions []string, expiresAt *time.Time) error // Get retrieves the access key record for a given ID. It returns // [store.ErrRecordNotFound] if no record exists for the specified ID. Get(ctx context.Context, id did.DID) (Record, error) + // ListByTenant retrieves all access key records for a given tenant. + ListByTenant(ctx context.Context, tenant did.DID) ([]Record, error) + // Delete removes the access key record for a given ID. It is idempotent: + // deleting an absent record returns nil. + Delete(ctx context.Context, id did.DID) error } diff --git a/pkg/store/accesskey/accesskey_test.go b/pkg/store/accesskey/accesskey_test.go index c21e0b6..94d354f 100644 --- a/pkg/store/accesskey/accesskey_test.go +++ b/pkg/store/accesskey/accesskey_test.go @@ -1,8 +1,10 @@ package accesskey_test import ( + "fmt" "runtime" "testing" + "time" "github.com/fil-forge/hilt/internal/testutil" "github.com/fil-forge/hilt/pkg/store" @@ -55,7 +57,7 @@ func TestAccessKeyStore(t *testing.T) { tenant := testutil.RandomDID(t) buckets := []did.DID{testutil.RandomDID(t), testutil.RandomDID(t)} perms := []string{"s3:GetObject", "s3:PutObject"} - require.NoError(t, s.Add(t.Context(), id, tenant, "ci-key", buckets, perms)) + require.NoError(t, s.Add(t.Context(), id, tenant, "ci-key", buckets, perms, nil)) rec, err := s.Get(t.Context(), id) require.NoError(t, err) @@ -64,13 +66,25 @@ func TestAccessKeyStore(t *testing.T) { require.Equal(t, "ci-key", rec.Name) require.Equal(t, buckets, rec.Buckets) require.Equal(t, perms, rec.Permissions) + require.Nil(t, rec.ExpiresAt) require.False(t, rec.CreatedAt.IsZero()) }) + t.Run("persists an expiry that round-trips", func(t *testing.T) { + id := testutil.RandomDID(t) + expires := time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC) + require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "exp", nil, []string{"s3:GetObject"}, &expires)) + + rec, err := s.Get(t.Context(), id) + require.NoError(t, err) + require.NotNil(t, rec.ExpiresAt) + require.True(t, expires.Equal(*rec.ExpiresAt)) + }) + t.Run("adds an access key with empty buckets (all-buckets)", func(t *testing.T) { id := testutil.RandomDID(t) perms := []string{"s3:ListAllMyBuckets"} - require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "all", nil, perms)) + require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "all", nil, perms, nil)) rec, err := s.Get(t.Context(), id) require.NoError(t, err) @@ -85,10 +99,47 @@ func TestAccessKeyStore(t *testing.T) { t.Run("Add returns ErrRecordExists for duplicate id", func(t *testing.T) { id := testutil.RandomDID(t) - require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "dup", nil, []string{"s3:GetObject"})) - err := s.Add(t.Context(), id, testutil.RandomDID(t), "dup", nil, []string{"s3:GetObject"}) + require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "dup", nil, []string{"s3:GetObject"}, nil)) + err := s.Add(t.Context(), id, testutil.RandomDID(t), "dup", nil, []string{"s3:GetObject"}, nil) require.ErrorIs(t, err, store.ErrRecordExists) }) + + t.Run("Add returns ErrRecordExists for duplicate (tenant, name)", func(t *testing.T) { + tenant := testutil.RandomDID(t) + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), tenant, "name-dup", nil, []string{"s3:GetObject"}, nil)) + // Same tenant + name but a different id must be rejected. + err := s.Add(t.Context(), testutil.RandomDID(t), tenant, "name-dup", nil, []string{"s3:GetObject"}, nil) + require.ErrorIs(t, err, store.ErrRecordExists) + // The same name under a different tenant is allowed. + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), testutil.RandomDID(t), "name-dup", nil, []string{"s3:GetObject"}, nil)) + }) + + t.Run("ListByTenant isolates by tenant", func(t *testing.T) { + tenant := testutil.RandomDID(t) + other := testutil.RandomDID(t) + for i := range 3 { + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), tenant, fmt.Sprintf("k%d", i), nil, []string{"s3:GetObject"}, nil)) + } + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), other, "k0", nil, []string{"s3:GetObject"}, nil)) + + recs, err := s.ListByTenant(t.Context(), tenant) + require.NoError(t, err) + require.Len(t, recs, 3) + for _, r := range recs { + require.Equal(t, tenant, r.Tenant) + } + }) + + t.Run("Delete removes an access key and is idempotent", func(t *testing.T) { + id := testutil.RandomDID(t) + require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "del", nil, []string{"s3:GetObject"}, nil)) + + require.NoError(t, s.Delete(t.Context(), id)) + _, err := s.Get(t.Context(), id) + require.ErrorIs(t, err, store.ErrRecordNotFound) + + require.NoError(t, s.Delete(t.Context(), id)) + }) }) } } diff --git a/pkg/store/accesskey/memory/store.go b/pkg/store/accesskey/memory/store.go index 06c98e8..ef863d9 100644 --- a/pkg/store/accesskey/memory/store.go +++ b/pkg/store/accesskey/memory/store.go @@ -22,19 +22,32 @@ func New() *Store { return &Store{keys: map[did.DID]accesskey.Record{}} } -func (s *Store) Add(ctx context.Context, id did.DID, tenant did.DID, name string, buckets []did.DID, permissions []string) error { +func (s *Store) Add(ctx context.Context, id did.DID, tenant did.DID, name string, buckets []did.DID, permissions []string, expiresAt *time.Time) error { s.mutex.Lock() defer s.mutex.Unlock() if _, ok := s.keys[id]; ok { return store.ErrRecordExists } + // Names must be unique within a tenant (mirrors the Postgres + // UNIQUE (tenant_id, name) constraint). + for _, rec := range s.keys { + if rec.Tenant == tenant && rec.Name == name { + return store.ErrRecordExists + } + } + var expires *time.Time + if expiresAt != nil { + e := expiresAt.UTC() + expires = &e + } s.keys[id] = accesskey.Record{ ID: id, Tenant: tenant, Name: name, Buckets: slices.Clone(buckets), Permissions: slices.Clone(permissions), + ExpiresAt: expires, CreatedAt: time.Now().UTC(), } return nil @@ -50,3 +63,24 @@ func (s *Store) Get(ctx context.Context, id did.DID) (accesskey.Record, error) { } return rec, nil } + +func (s *Store) ListByTenant(ctx context.Context, tenant did.DID) ([]accesskey.Record, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + var recs []accesskey.Record + for _, rec := range s.keys { + if rec.Tenant == tenant { + recs = append(recs, rec) + } + } + return recs, nil +} + +func (s *Store) Delete(ctx context.Context, id did.DID) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + delete(s.keys, id) + return nil +} diff --git a/pkg/store/accesskey/postgres/store.go b/pkg/store/accesskey/postgres/store.go index b152ca9..884df17 100644 --- a/pkg/store/accesskey/postgres/store.go +++ b/pkg/store/accesskey/postgres/store.go @@ -29,7 +29,7 @@ func New(pool *pgxpool.Pool) *Store { // Initialize is a no-op. Schema is managed by the shared goose migrations. func (s *Store) Initialize(ctx context.Context) error { return nil } -func (s *Store) Add(ctx context.Context, id did.DID, tenant did.DID, name string, buckets []did.DID, permissions []string) error { +func (s *Store) Add(ctx context.Context, id did.DID, tenant did.DID, name string, buckets []did.DID, permissions []string, expiresAt *time.Time) error { bucketStrs := make([]string, len(buckets)) for i, b := range buckets { bucketStrs[i] = b.String() @@ -37,10 +37,15 @@ func (s *Store) Add(ctx context.Context, id did.DID, tenant did.DID, name string if permissions == nil { permissions = []string{} } + var expires *time.Time + if expiresAt != nil { + e := expiresAt.UTC() + expires = &e + } _, err := s.pool.Exec(ctx, ` - INSERT INTO access_key (id, tenant_id, name, buckets, permissions, created_at) - VALUES ($1, $2, $3, $4, $5, $6) - `, id.String(), tenant.String(), name, bucketStrs, permissions, time.Now().UTC()) + INSERT INTO access_key (id, tenant_id, name, buckets, permissions, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, id.String(), tenant.String(), name, bucketStrs, permissions, expires, time.Now().UTC()) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { @@ -53,35 +58,75 @@ func (s *Store) Add(ctx context.Context, id did.DID, tenant did.DID, name string func (s *Store) Get(ctx context.Context, id did.DID) (accesskey.Record, error) { row := s.pool.QueryRow(ctx, ` - SELECT id, tenant_id, name, buckets, permissions, created_at + SELECT id, tenant_id, name, buckets, permissions, expires_at, created_at FROM access_key WHERE id = $1 `, id.String()) + rec, err := scanRecord(row) + if errors.Is(err, pgx.ErrNoRows) { + return accesskey.Record{}, store.ErrRecordNotFound + } + if err != nil { + return accesskey.Record{}, fmt.Errorf("getting access key: %w", err) + } + return rec, nil +} + +func (s *Store) ListByTenant(ctx context.Context, tenant did.DID) ([]accesskey.Record, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, tenant_id, name, buckets, permissions, expires_at, created_at + FROM access_key + WHERE tenant_id = $1 + ORDER BY id ASC + `, tenant.String()) + if err != nil { + return nil, fmt.Errorf("listing access keys by tenant: %w", err) + } + defer rows.Close() + + var recs []accesskey.Record + for rows.Next() { + rec, err := scanRecord(rows) + if err != nil { + return nil, err + } + recs = append(recs, rec) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterating access keys: %w", err) + } + return recs, nil +} + +func (s *Store) Delete(ctx context.Context, id did.DID) error { + if _, err := s.pool.Exec(ctx, `DELETE FROM access_key WHERE id = $1`, id.String()); err != nil { + return fmt.Errorf("deleting access key: %w", err) + } + return nil +} +func scanRecord(row pgx.Row) (accesskey.Record, error) { var ( idStr string tenantID *string name *string bucketStrs []string perms []string + expiresAt *time.Time createdAt time.Time ) - err := row.Scan(&idStr, &tenantID, &name, &bucketStrs, &perms, &createdAt) - if errors.Is(err, pgx.ErrNoRows) { - return accesskey.Record{}, store.ErrRecordNotFound - } - if err != nil { - return accesskey.Record{}, fmt.Errorf("getting access key: %w", err) + if err := row.Scan(&idStr, &tenantID, &name, &bucketStrs, &perms, &expiresAt, &createdAt); err != nil { + return accesskey.Record{}, err } - parsedID, err := did.Parse(idStr) + id, err := did.Parse(idStr) if err != nil { return accesskey.Record{}, fmt.Errorf("parsing access key DID: %w", err) } rec := accesskey.Record{ - ID: parsedID, - Name: "", + ID: id, Permissions: perms, + ExpiresAt: expiresAt, CreatedAt: createdAt, } if tenantID != nil && *tenantID != "" { diff --git a/pkg/store/bucket/bucket.go b/pkg/store/bucket/bucket.go index fbbf0c6..9566252 100644 --- a/pkg/store/bucket/bucket.go +++ b/pkg/store/bucket/bucket.go @@ -2,11 +2,17 @@ package bucket import ( "context" + "errors" "time" + "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/ucantone/did" ) +// ErrConflictingFilters is returned by [Store.ListByTenant] when both [WithIDs] +// and [WithNames] are supplied. +var ErrConflictingFilters = errors.New("bucket: WithIDs and WithNames cannot be combined") + type Record struct { // Identifier for the bucket. ID did.DID @@ -18,6 +24,42 @@ type Record struct { CreatedAt time.Time } +// ListConfig configures [Store.ListByTenant]. +type ListConfig struct { + store.PaginationConfig // promotes Cursor and Limit + // IDs optionally restricts results to buckets with these IDs. When empty, no + // ID filtering is applied. + IDs []did.DID + // Names optionally restricts results to buckets with these names. When empty, + // no name filtering is applied. Must not be combined with IDs. + Names []string +} + +// ListOption configures a [ListConfig]. +type ListOption func(*ListConfig) + +// WithIDs restricts results to buckets with the given IDs. It must not be +// combined with [WithNames]. +func WithIDs(ids ...did.DID) ListOption { + return func(c *ListConfig) { c.IDs = ids } +} + +// WithNames restricts results to buckets with the given names. It must not be +// combined with [WithIDs]. +func WithNames(names ...string) ListOption { + return func(c *ListConfig) { c.Names = names } +} + +// WithLimit sets the maximum number of results per page. +func WithLimit(limit int) ListOption { + return func(c *ListConfig) { c.Limit = &limit } +} + +// WithCursor sets the page cursor (the bucket ID after which to start). +func WithCursor(cursor string) ListOption { + return func(c *ListConfig) { c.Cursor = &cursor } +} + type Store interface { // Add creates a new bucket record. It returns [store.ErrRecordExists] if a // record with the same ID already exists. @@ -25,4 +67,11 @@ type Store interface { // GetByName retrieves the bucket record for a given name. It returns // [store.ErrRecordNotFound] if no bucket exists with the specified name. GetByName(ctx context.Context, name string) (Record, error) + // ListByTenant retrieves a paginated list of bucket records for a given + // tenant, optionally filtered to a set of bucket IDs (see [WithIDs]) or names + // (see [WithNames]). Supplying both filters returns [ErrConflictingFilters]. + ListByTenant(ctx context.Context, tenant did.DID, opts ...ListOption) (store.Page[Record], error) + // Delete removes the bucket record for a given ID. It is idempotent: + // deleting an absent record returns nil. + Delete(ctx context.Context, id did.DID) error } diff --git a/pkg/store/bucket/bucket_test.go b/pkg/store/bucket/bucket_test.go index c327add..dac33b9 100644 --- a/pkg/store/bucket/bucket_test.go +++ b/pkg/store/bucket/bucket_test.go @@ -1,6 +1,8 @@ package bucket_test import ( + "context" + "fmt" "runtime" "testing" @@ -9,6 +11,7 @@ import ( "github.com/fil-forge/hilt/pkg/store/bucket" bucketmemory "github.com/fil-forge/hilt/pkg/store/bucket/memory" bucketpostgres "github.com/fil-forge/hilt/pkg/store/bucket/postgres" + "github.com/fil-forge/ucantone/did" "github.com/stretchr/testify/require" ) @@ -79,6 +82,85 @@ func TestBucketStore(t *testing.T) { err := s.Add(t.Context(), testutil.RandomDID(t), testutil.RandomDID(t), "shared-name") require.ErrorIs(t, err, store.ErrRecordExists) }) + + t.Run("ListByTenant isolates and paginates by tenant", func(t *testing.T) { + tenant := testutil.RandomDID(t) + other := testutil.RandomDID(t) + for i := range 5 { + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), tenant, fmt.Sprintf("lbt-%d", i))) + } + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), other, "lbt-other")) + + all, err := store.Collect(t.Context(), func(ctx context.Context, opts store.PaginationConfig) (store.Page[bucket.Record], error) { + listOpts := []bucket.ListOption{bucket.WithLimit(2)} + if opts.Cursor != nil { + listOpts = append(listOpts, bucket.WithCursor(*opts.Cursor)) + } + return s.ListByTenant(ctx, tenant, listOpts...) + }) + require.NoError(t, err) + require.Len(t, all, 5) + for _, b := range all { + require.Equal(t, tenant, b.Tenant) + } + }) + + t.Run("ListByTenant filters by IDs", func(t *testing.T) { + tenant := testutil.RandomDID(t) + want := []did.DID{testutil.RandomDID(t), testutil.RandomDID(t)} + require.NoError(t, s.Add(t.Context(), want[0], tenant, "fbid-a")) + require.NoError(t, s.Add(t.Context(), want[1], tenant, "fbid-b")) + // Decoys: same tenant but not requested, and a different tenant. + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), tenant, "fbid-c")) + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), testutil.RandomDID(t), "fbid-other")) + + page, err := s.ListByTenant(t.Context(), tenant, bucket.WithIDs(want...)) + require.NoError(t, err) + got := make([]did.DID, 0, len(page.Results)) + for _, b := range page.Results { + require.Equal(t, tenant, b.Tenant) + got = append(got, b.ID) + } + require.ElementsMatch(t, want, got) + }) + + t.Run("ListByTenant filters by names", func(t *testing.T) { + tenant := testutil.RandomDID(t) + want := []did.DID{testutil.RandomDID(t), testutil.RandomDID(t)} + require.NoError(t, s.Add(t.Context(), want[0], tenant, "fbn-a")) + require.NoError(t, s.Add(t.Context(), want[1], tenant, "fbn-b")) + // Decoys: same tenant but not requested, and a different tenant. + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), tenant, "fbn-c")) + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), testutil.RandomDID(t), "fbn-other")) + + // "fbn-other" belongs to a different tenant, so it is excluded by the + // tenant scope even though it is requested. + page, err := s.ListByTenant(t.Context(), tenant, bucket.WithNames("fbn-a", "fbn-b", "fbn-other")) + require.NoError(t, err) + got := make([]did.DID, 0, len(page.Results)) + for _, b := range page.Results { + require.Equal(t, tenant, b.Tenant) + got = append(got, b.ID) + } + require.ElementsMatch(t, want, got) + }) + + t.Run("ListByTenant rejects IDs and Names together", func(t *testing.T) { + _, err := s.ListByTenant(t.Context(), testutil.RandomDID(t), + bucket.WithIDs(testutil.RandomDID(t)), bucket.WithNames("x")) + require.ErrorIs(t, err, bucket.ErrConflictingFilters) + }) + + t.Run("Delete removes a bucket and is idempotent", func(t *testing.T) { + id := testutil.RandomDID(t) + require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "to-delete")) + + require.NoError(t, s.Delete(t.Context(), id)) + _, err := s.GetByName(t.Context(), "to-delete") + require.ErrorIs(t, err, store.ErrRecordNotFound) + + require.NoError(t, s.Delete(t.Context(), id)) + }) }) } } diff --git a/pkg/store/bucket/memory/store.go b/pkg/store/bucket/memory/store.go index 0ac2d2c..f7217ab 100644 --- a/pkg/store/bucket/memory/store.go +++ b/pkg/store/bucket/memory/store.go @@ -2,6 +2,8 @@ package memory import ( "context" + "slices" + "strings" "sync" "time" @@ -10,6 +12,8 @@ import ( "github.com/fil-forge/ucantone/did" ) +const defaultListLimit = 1000 + type Store struct { mutex sync.RWMutex buckets map[did.DID]bucket.Record @@ -50,3 +54,74 @@ func (s *Store) GetByName(ctx context.Context, name string) (bucket.Record, erro } return bucket.Record{}, store.ErrRecordNotFound } + +func (s *Store) ListByTenant(ctx context.Context, tenant did.DID, opts ...bucket.ListOption) (store.Page[bucket.Record], error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + limit := defaultListLimit + cfg := bucket.ListConfig{PaginationConfig: store.PaginationConfig{Limit: &limit}} + for _, opt := range opts { + opt(&cfg) + } + if len(cfg.IDs) > 0 && len(cfg.Names) > 0 { + return store.Page[bucket.Record]{}, bucket.ErrConflictingFilters + } + + var idFilter map[did.DID]bool + if len(cfg.IDs) > 0 { + idFilter = make(map[did.DID]bool, len(cfg.IDs)) + for _, id := range cfg.IDs { + idFilter[id] = true + } + } + var nameFilter map[string]bool + if len(cfg.Names) > 0 { + nameFilter = make(map[string]bool, len(cfg.Names)) + for _, name := range cfg.Names { + nameFilter[name] = true + } + } + + var recs []bucket.Record + for _, b := range s.buckets { + if b.Tenant != tenant { + continue + } + if idFilter != nil && !idFilter[b.ID] { + continue + } + if nameFilter != nil && !nameFilter[b.Name] { + continue + } + recs = append(recs, b) + } + slices.SortFunc(recs, func(a, b bucket.Record) int { + return strings.Compare(a.ID.String(), b.ID.String()) + }) + + if cfg.Cursor != nil { + for i, r := range recs { + if r.ID.String() == *cfg.Cursor { + recs = recs[i+1:] + break + } + } + } + + var cursor *string + if cfg.Limit != nil && len(recs) > *cfg.Limit { + recs = recs[:*cfg.Limit] + last := recs[len(recs)-1].ID.String() + cursor = &last + } + return store.Page[bucket.Record]{Cursor: cursor, Results: recs}, nil +} + +func (s *Store) Delete(ctx context.Context, id did.DID) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + delete(s.buckets, id) + return nil +} diff --git a/pkg/store/bucket/postgres/store.go b/pkg/store/bucket/postgres/store.go index 177464e..99d65ca 100644 --- a/pkg/store/bucket/postgres/store.go +++ b/pkg/store/bucket/postgres/store.go @@ -60,11 +60,81 @@ func (s *Store) GetByName(ctx context.Context, name string) (bucket.Record, erro return rec, nil } -type rowScanner interface { - Scan(dest ...any) error +const defaultListLimit = 1000 + +func (s *Store) ListByTenant(ctx context.Context, tenant did.DID, opts ...bucket.ListOption) (store.Page[bucket.Record], error) { + cfg := bucket.ListConfig{} + for _, opt := range opts { + opt(&cfg) + } + if len(cfg.IDs) > 0 && len(cfg.Names) > 0 { + return store.Page[bucket.Record]{}, bucket.ErrConflictingFilters + } + limit := defaultListLimit + if cfg.Limit != nil && *cfg.Limit > 0 { + limit = *cfg.Limit + } + + // $1 = tenant, $2 = limit+1; further filters use dynamic placeholders. + args := []any{tenant.String(), limit + 1} + query := ` + SELECT id, tenant_id, name, created_at + FROM bucket + WHERE tenant_id = $1 + ` + if len(cfg.IDs) > 0 { + ids := make([]string, len(cfg.IDs)) + for i, id := range cfg.IDs { + ids[i] = id.String() + } + args = append(args, ids) + query += fmt.Sprintf(" AND id = ANY($%d)", len(args)) + } + if len(cfg.Names) > 0 { + args = append(args, cfg.Names) + query += fmt.Sprintf(" AND name = ANY($%d)", len(args)) + } + if cfg.Cursor != nil { + args = append(args, *cfg.Cursor) + query += fmt.Sprintf(" AND id > $%d", len(args)) + } + query += ` ORDER BY id ASC LIMIT $2` + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return store.Page[bucket.Record]{}, fmt.Errorf("listing buckets by tenant: %w", err) + } + defer rows.Close() + + recs := make([]bucket.Record, 0, limit) + for rows.Next() { + rec, err := scanRecord(rows) + if err != nil { + return store.Page[bucket.Record]{}, err + } + recs = append(recs, rec) + } + if err := rows.Err(); err != nil { + return store.Page[bucket.Record]{}, fmt.Errorf("iterating buckets: %w", err) + } + + var cursor *string + if len(recs) > limit { + last := recs[limit-1].ID.String() + cursor = &last + recs = recs[:limit] + } + return store.Page[bucket.Record]{Cursor: cursor, Results: recs}, nil +} + +func (s *Store) Delete(ctx context.Context, id did.DID) error { + if _, err := s.pool.Exec(ctx, `DELETE FROM bucket WHERE id = $1`, id.String()); err != nil { + return fmt.Errorf("deleting bucket: %w", err) + } + return nil } -func scanRecord(row rowScanner) (bucket.Record, error) { +func scanRecord(row pgx.Row) (bucket.Record, error) { var ( idStr string tenantID *string diff --git a/pkg/store/tenant/memory/store.go b/pkg/store/tenant/memory/store.go index f0e7b5d..a933b0f 100644 --- a/pkg/store/tenant/memory/store.go +++ b/pkg/store/tenant/memory/store.go @@ -21,19 +21,25 @@ func New() *Store { return &Store{tenants: map[did.DID]tenant.Record{}} } -func (s *Store) Add(ctx context.Context, id did.DID, provider did.DID, name string, status tenant.Status) error { +func (s *Store) Add(ctx context.Context, id did.DID, externalID string, provider did.DID, name string, status tenant.Status) error { s.mutex.Lock() defer s.mutex.Unlock() if _, ok := s.tenants[id]; ok { return store.ErrRecordExists } + for _, rec := range s.tenants { + if rec.ExternalID == externalID { + return store.ErrRecordExists + } + } s.tenants[id] = tenant.Record{ - ID: id, - Provider: provider, - Name: name, - Status: status, - CreatedAt: time.Now().UTC(), + ID: id, + ExternalID: externalID, + Provider: provider, + Name: name, + Status: status, + CreatedAt: time.Now().UTC(), } return nil } @@ -49,6 +55,18 @@ func (s *Store) Get(ctx context.Context, id did.DID) (tenant.Record, error) { return rec, nil } +func (s *Store) GetByExternalID(ctx context.Context, externalID string) (tenant.Record, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + for _, rec := range s.tenants { + if rec.ExternalID == externalID { + return rec, nil + } + } + return tenant.Record{}, store.ErrRecordNotFound +} + func (s *Store) SetStatus(ctx context.Context, id did.DID, status tenant.Status) error { s.mutex.Lock() defer s.mutex.Unlock() @@ -62,3 +80,11 @@ func (s *Store) SetStatus(ctx context.Context, id did.DID, status tenant.Status) s.tenants[id] = rec return nil } + +func (s *Store) Delete(ctx context.Context, id did.DID) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + delete(s.tenants, id) + return nil +} diff --git a/pkg/store/tenant/postgres/store.go b/pkg/store/tenant/postgres/store.go index 55df17e..c902f67 100644 --- a/pkg/store/tenant/postgres/store.go +++ b/pkg/store/tenant/postgres/store.go @@ -29,11 +29,11 @@ func New(pool *pgxpool.Pool) *Store { // Initialize is a no-op. Schema is managed by the shared goose migrations. func (s *Store) Initialize(ctx context.Context) error { return nil } -func (s *Store) Add(ctx context.Context, id did.DID, provider did.DID, name string, status tenant.Status) error { +func (s *Store) Add(ctx context.Context, id did.DID, externalID string, provider did.DID, name string, status tenant.Status) error { _, err := s.pool.Exec(ctx, ` - INSERT INTO tenant (id, provider_id, name, status, created_at) - VALUES ($1, $2, $3, $4, $5) - `, id.String(), provider.String(), name, string(status), time.Now().UTC()) + INSERT INTO tenant (id, external_id, provider_id, name, status, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, id.String(), externalID, provider.String(), name, string(status), time.Now().UTC()) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { @@ -46,7 +46,7 @@ func (s *Store) Add(ctx context.Context, id did.DID, provider did.DID, name stri func (s *Store) Get(ctx context.Context, id did.DID) (tenant.Record, error) { row := s.pool.QueryRow(ctx, ` - SELECT id, provider_id, name, status, created_at, updated_at + SELECT id, external_id, provider_id, name, status, created_at, updated_at FROM tenant WHERE id = $1 `, id.String()) @@ -60,6 +60,22 @@ func (s *Store) Get(ctx context.Context, id did.DID) (tenant.Record, error) { return rec, nil } +func (s *Store) GetByExternalID(ctx context.Context, externalID string) (tenant.Record, error) { + row := s.pool.QueryRow(ctx, ` + SELECT id, external_id, provider_id, name, status, created_at, updated_at + FROM tenant + WHERE external_id = $1 + `, externalID) + rec, err := scanRecord(row) + if errors.Is(err, pgx.ErrNoRows) { + return tenant.Record{}, store.ErrRecordNotFound + } + if err != nil { + return tenant.Record{}, fmt.Errorf("getting tenant by external id: %w", err) + } + return rec, nil +} + func (s *Store) SetStatus(ctx context.Context, id did.DID, status tenant.Status) error { tag, err := s.pool.Exec(ctx, ` UPDATE tenant @@ -75,6 +91,13 @@ func (s *Store) SetStatus(ctx context.Context, id did.DID, status tenant.Status) return nil } +func (s *Store) Delete(ctx context.Context, id did.DID) error { + if _, err := s.pool.Exec(ctx, `DELETE FROM tenant WHERE id = $1`, id.String()); err != nil { + return fmt.Errorf("deleting tenant: %w", err) + } + return nil +} + type rowScanner interface { Scan(dest ...any) error } @@ -82,13 +105,14 @@ type rowScanner interface { func scanRecord(row rowScanner) (tenant.Record, error) { var ( idStr string + externalID *string providerID *string name *string status string createdAt time.Time updatedAt *time.Time ) - if err := row.Scan(&idStr, &providerID, &name, &status, &createdAt, &updatedAt); err != nil { + if err := row.Scan(&idStr, &externalID, &providerID, &name, &status, &createdAt, &updatedAt); err != nil { return tenant.Record{}, err } id, err := did.Parse(idStr) @@ -100,6 +124,9 @@ func scanRecord(row rowScanner) (tenant.Record, error) { Status: tenant.Status(status), CreatedAt: createdAt, } + if externalID != nil { + rec.ExternalID = *externalID + } if providerID != nil && *providerID != "" { provider, err := did.Parse(*providerID) if err != nil { diff --git a/pkg/store/tenant/tenant.go b/pkg/store/tenant/tenant.go index c5cc2af..1e37a32 100644 --- a/pkg/store/tenant/tenant.go +++ b/pkg/store/tenant/tenant.go @@ -16,8 +16,12 @@ const ( ) type Record struct { - // Identifier for the tenant. + // Identifier for the tenant (the tenant's did:plc, the internal crypto + // identity). ID did.DID + // ExternalID is the tenant identifier used by the Tenant API (the {tenantId} + // path parameter supplied by the caller). + ExternalID string // Provider this tenant belongs to. Provider did.DID // Human readable name of the tenant. @@ -32,12 +36,19 @@ type Record struct { type Store interface { // Add creates a new tenant record. It returns [store.ErrRecordExists] if a - // record with the same ID already exists. - Add(ctx context.Context, id did.DID, provider did.DID, name string, status Status) error + // record with the same ID or external ID already exists. + Add(ctx context.Context, id did.DID, externalID string, provider did.DID, name string, status Status) error // Get retrieves the tenant record for a given ID. It returns // [store.ErrRecordNotFound] if no record exists for the specified ID. Get(ctx context.Context, id did.DID) (Record, error) + // GetByExternalID retrieves the tenant record for a given external ID. It + // returns [store.ErrRecordNotFound] if no record exists for the specified + // external ID. + GetByExternalID(ctx context.Context, externalID string) (Record, error) // SetStatus updates the status of a tenant record. It returns // [store.ErrRecordNotFound] if no record exists for the specified ID. SetStatus(ctx context.Context, id did.DID, status Status) error + // Delete removes the tenant record for a given ID. It is idempotent: + // deleting an absent record returns nil. + Delete(ctx context.Context, id did.DID) error } diff --git a/pkg/store/tenant/tenant_test.go b/pkg/store/tenant/tenant_test.go index 844df67..bbc7719 100644 --- a/pkg/store/tenant/tenant_test.go +++ b/pkg/store/tenant/tenant_test.go @@ -52,11 +52,12 @@ func TestTenantStore(t *testing.T) { t.Run("adds and retrieves a tenant", func(t *testing.T) { id := testutil.RandomDID(t) provider := testutil.RandomDID(t) - require.NoError(t, s.Add(t.Context(), id, provider, "acme", tenant.Active)) + require.NoError(t, s.Add(t.Context(), id, "ext-acme", provider, "acme", tenant.Active)) rec, err := s.Get(t.Context(), id) require.NoError(t, err) require.Equal(t, id, rec.ID) + require.Equal(t, "ext-acme", rec.ExternalID) require.Equal(t, provider, rec.Provider) require.Equal(t, "acme", rec.Name) require.Equal(t, tenant.Active, rec.Status) @@ -68,16 +69,37 @@ func TestTenantStore(t *testing.T) { require.ErrorIs(t, err, store.ErrRecordNotFound) }) + t.Run("GetByExternalID retrieves a tenant", func(t *testing.T) { + id := testutil.RandomDID(t) + require.NoError(t, s.Add(t.Context(), id, "ext-lookup", testutil.RandomDID(t), "lookup", tenant.Active)) + + rec, err := s.GetByExternalID(t.Context(), "ext-lookup") + require.NoError(t, err) + require.Equal(t, id, rec.ID) + require.Equal(t, "ext-lookup", rec.ExternalID) + }) + + t.Run("GetByExternalID returns ErrRecordNotFound for unknown external id", func(t *testing.T) { + _, err := s.GetByExternalID(t.Context(), "ext-missing") + require.ErrorIs(t, err, store.ErrRecordNotFound) + }) + t.Run("Add returns ErrRecordExists for duplicate id", func(t *testing.T) { id := testutil.RandomDID(t) - require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "dup", tenant.Active)) - err := s.Add(t.Context(), id, testutil.RandomDID(t), "dup", tenant.Active) + require.NoError(t, s.Add(t.Context(), id, "ext-dup-1", testutil.RandomDID(t), "dup", tenant.Active)) + err := s.Add(t.Context(), id, "ext-dup-2", testutil.RandomDID(t), "dup", tenant.Active) + require.ErrorIs(t, err, store.ErrRecordExists) + }) + + t.Run("Add returns ErrRecordExists for duplicate external id", func(t *testing.T) { + require.NoError(t, s.Add(t.Context(), testutil.RandomDID(t), "ext-shared", testutil.RandomDID(t), "a", tenant.Active)) + err := s.Add(t.Context(), testutil.RandomDID(t), "ext-shared", testutil.RandomDID(t), "b", tenant.Active) require.ErrorIs(t, err, store.ErrRecordExists) }) t.Run("SetStatus updates status", func(t *testing.T) { id := testutil.RandomDID(t) - require.NoError(t, s.Add(t.Context(), id, testutil.RandomDID(t), "switcher", tenant.Active)) + require.NoError(t, s.Add(t.Context(), id, "ext-switcher", testutil.RandomDID(t), "switcher", tenant.Active)) require.NoError(t, s.SetStatus(t.Context(), id, tenant.WriteLocked)) @@ -91,6 +113,18 @@ func TestTenantStore(t *testing.T) { err := s.SetStatus(t.Context(), testutil.RandomDID(t), tenant.Disabled) require.ErrorIs(t, err, store.ErrRecordNotFound) }) + + t.Run("Delete removes a tenant and is idempotent", func(t *testing.T) { + id := testutil.RandomDID(t) + require.NoError(t, s.Add(t.Context(), id, "ext-del", testutil.RandomDID(t), "del", tenant.Active)) + + require.NoError(t, s.Delete(t.Context(), id)) + _, err := s.Get(t.Context(), id) + require.ErrorIs(t, err, store.ErrRecordNotFound) + + // Deleting an absent record is a no-op. + require.NoError(t, s.Delete(t.Context(), id)) + }) }) } }