diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5cf77fd --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +hilt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4ffcc68 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +BINARY := hilt +BIN_DIR := . +CMD := ./cmd + +.PHONY: build test vet clean + +build: + go build -o $(BIN_DIR)/$(BINARY) $(CMD) + +test: + go test ./... + +vet: + go vet ./... + +clean: + rm $(BIN_DIR)/$(BINARY) diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..32df911 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "go.uber.org/fx" + "go.uber.org/fx/fxevent" + "go.uber.org/zap" + + "github.com/fil-forge/hilt/pkg/config" + appfx "github.com/fil-forge/hilt/pkg/fx" +) + +var cfgFile string + +func main() { + rootCmd := &cobra.Command{ + Use: "hilt", + Short: "Hilt tenant management service", + Long: "Hilt manages tenants of Ingot and their secret keys.", + } + + serveCmd := &cobra.Command{ + Use: "serve", + Short: "Start the hilt service", + 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") + + // storage config + serveCmd.Flags().String("storage", "postgres", "storage backend (memory or postgres)") + serveCmd.Flags().String("postgres-dsn", "", "postgres connection string (used when storage=postgres)") + serveCmd.Flags().Bool("skip-migrations", false, "skip running postgres migrations on startup") + + // vault config + serveCmd.Flags().String("vault", "hashicorp", "vault backend for private keys (hashicorp or memory)") + serveCmd.Flags().String("hashicorp-address", "http://127.0.0.1:8200", "hashicorp vault server address") + serveCmd.Flags().String("hashicorp-mount", "secret", "hashicorp vault KV v2 secrets engine mount path") + serveCmd.Flags().String("hashicorp-auth-method", "approle", "hashicorp vault auth method (approle or token)") + serveCmd.Flags().String("hashicorp-token", "", "hashicorp vault token (auth-method=token; prefer HILT_VAULT_HASHICORP_TOKEN env var or config file to avoid exposing via process args)") + serveCmd.Flags().String("hashicorp-approle-role-id", "", "hashicorp vault AppRole role ID (auth-method=approle; prefer HILT_VAULT_HASHICORP_APPROLE_ROLE_ID env var or config file)") + 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", "", "CSV partner bearer key(s) required on Tenant API requests (prefer HILT_AUTH_PARTNER_KEY env var or config file to avoid exposing via process args)") + + rootCmd.AddCommand(serveCmd) + + rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (default: looks for config.yaml in current dir)") + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func runServe(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(cfgFile, config.WithFlagSet(cmd.Flags())) + if err != nil { + return err + } + app := fx.New( + appfx.AppModule(cfg), + // Suppress fx's default logging and use our own zap logger. + fx.WithLogger(func(log *zap.Logger) fxevent.Logger { + return &fxevent.ZapLogger{Logger: log} + }), + ) + app.Run() + + return nil +} diff --git a/go.mod b/go.mod index 89f978b..6061158 100644 --- a/go.mod +++ b/go.mod @@ -4,14 +4,22 @@ 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 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + go.uber.org/fx v1.24.0 go.uber.org/zap v1.28.0 ) @@ -19,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 @@ -33,20 +41,31 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.1 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/minio/sha256-simd v1.0.1 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect @@ -61,23 +80,33 @@ 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 github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + 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 @@ -86,13 +115,17 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.uber.org/dig v1.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.50.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.14.0 // indirect 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 cbb4396..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= @@ -24,9 +24,11 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= @@ -43,10 +45,14 @@ 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= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -54,6 +60,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -63,8 +71,25 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= +github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= +github.com/hashicorp/go-retryablehttp v0.7.1 h1:sUiuQAnLlbvmExtFQs72iFW/HXeUn8Z1aJLQ4LJJbTQ= +github.com/hashicorp/go-retryablehttp v0.7.1/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= +github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/ipfs/go-cid v0.6.1 h1:T5TnNb08+ueovG76Z5gx1L4Y7QOaGTXHg1F6raWFxIc= github.com/ipfs/go-cid v0.6.1/go.mod h1:zrY0SwOhjrrIdfPQ/kf+k1sXyJ0QE7cMxfCployLBs0= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -81,6 +106,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ24= +github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= @@ -89,6 +118,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -97,6 +128,8 @@ github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6B github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -139,6 +172,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -152,21 +187,42 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= @@ -175,6 +231,10 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= @@ -203,6 +263,10 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= +go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= +go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= +go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -211,8 +275,8 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= @@ -222,14 +286,15 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= @@ -261,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/internal/testutil/vault.go b/internal/testutil/vault.go new file mode 100644 index 0000000..f543329 --- /dev/null +++ b/internal/testutil/vault.go @@ -0,0 +1,49 @@ +package testutil + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// VaultRootToken is the dev-mode root token used by the throwaway Vault +// container created by CreateVault. +const VaultRootToken = "root" + +// CreateVault starts a throwaway HashiCorp Vault dev-mode container (which +// auto-mounts a KV v2 engine at "secret") and returns its address and root +// token. The container is cleaned up when the test finishes. +func CreateVault(t *testing.T) (address, token string) { + t.Helper() + + ctx := t.Context() + req := testcontainers.ContainerRequest{ + Image: "hashicorp/vault:1.15", + ExposedPorts: []string{"8200/tcp"}, + Cmd: []string{"server", "-dev"}, + Env: map[string]string{ + "VAULT_DEV_ROOT_TOKEN_ID": VaultRootToken, + "VAULT_DEV_LISTEN_ADDRESS": "0.0.0.0:8200", + }, + WaitingFor: wait.ForLog("Vault server started!").WithStartupTimeout(30 * time.Second), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + require.NoError(t, err) + testcontainers.CleanupContainer(t, container) + + host, err := container.Host(ctx) + require.NoError(t, err) + port, err := container.MappedPort(ctx, "8200/tcp") + require.NoError(t, err) + + address = fmt.Sprintf("http://%s:%s", host, port.Port()) + t.Logf("Vault address: %s", address) + return address, VaultRootToken +} diff --git a/pkg/api/access_keys.go b/pkg/api/access_keys.go new file mode 100644 index 0000000..8174418 --- /dev/null +++ b/pkg/api/access_keys.go @@ -0,0 +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) 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, + 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, + 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): 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 new file mode 100644 index 0000000..e0d1f85 --- /dev/null +++ b/pkg/api/route.go @@ -0,0 +1,22 @@ +// Package api defines the HTTP handlers for the Hilt tenant management API +// (the fil-one service orchestrator "Tenant API"). Handlers are exposed as +// [Route] values, collected via fx and registered on the echo server. +package api + +import ( + "github.com/labstack/echo/v4" +) + +// Route maps an HTTP method and path to the echo handler that serves it. A +// Route can be carried as a value — e.g. collected via dependency injection — +// and registered on an echo server later. +type Route struct { + Method string + Path string + Handler echo.HandlerFunc +} + +// NewRoute builds a [Route] from a method, path, and handler. +func NewRoute(method, path string, handler echo.HandlerFunc) Route { + return Route{Method: method, Path: path, Handler: handler} +} diff --git a/pkg/api/tenants.go b/pkg/api/tenants.go new file mode 100644 index 0000000..f59137b --- /dev/null +++ b/pkg/api/tenants.go @@ -0,0 +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: 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, 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), 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) + }) +} + +// 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/api/types.go b/pkg/api/types.go new file mode 100644 index 0000000..a6f70ba --- /dev/null +++ b/pkg/api/types.go @@ -0,0 +1,65 @@ +package api + +import "time" + +// TenantStatus is the access mode of a tenant. +type TenantStatus string + +const ( + TenantStatusActive TenantStatus = "active" + TenantStatusWriteLocked TenantStatus = "write-locked" + TenantStatusDisabled TenantStatus = "disabled" +) + +// Tenant is the operational state and quotas for a tenant. +type Tenant struct { + TenantID string `json:"tenantId"` + DisplayName string `json:"displayName"` + Status TenantStatus `json:"status"` + BucketCount int `json:"bucketCount"` + BucketLimit int `json:"bucketLimit"` + AccessKeyCount int `json:"accessKeyCount"` + AccessKeyLimit int `json:"accessKeyLimit"` + CreatedAt time.Time `json:"createdAt"` +} + +// ProvisionTenantRequest is the body of PUT /tenants/{tenantId}. +type ProvisionTenantRequest struct { + DisplayName string `json:"displayName"` + Region string `json:"region"` +} + +// UpdateTenantStatusRequest is the body of POST /tenants/{tenantId}/status. +type UpdateTenantStatusRequest struct { + Status TenantStatus `json:"status"` +} + +// AccessKey is the metadata for an S3 access key (never includes the secret). +type AccessKey struct { + AccessKeyID string `json:"accessKeyId"` + Name string `json:"name"` + Permissions []string `json:"permissions"` + Buckets []string `json:"buckets,omitempty"` + ExpiresAt *time.Time `json:"expiresAt"` + CreatedAt time.Time `json:"createdAt"` +} + +// CreatedAccessKey is returned only by POST /tenants/{tenantId}/access-keys and +// is the one time the secret access key is exposed. +type CreatedAccessKey struct { + AccessKey + SecretAccessKey string `json:"secretAccessKey"` +} + +// AccessKeyList is the body of GET /tenants/{tenantId}/access-keys. +type AccessKeyList struct { + Items []AccessKey `json:"items"` +} + +// CreateAccessKeyRequest is the body of POST /tenants/{tenantId}/access-keys. +type CreateAccessKeyRequest struct { + Name string `json:"name"` + Permissions []string `json:"permissions"` + Buckets []string `json:"buckets,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..6566ff2 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,246 @@ +// Package config loads hilt service configuration from a config file, +// environment variables (HILT_ prefix), and built-in defaults. +package config + +import ( + "fmt" + "strings" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +// Valid values for StorageConfig.Type. +const ( + StorageTypeMemory = "memory" + StorageTypePostgres = "postgres" +) + +// Valid values for VaultConfig.Type. +const ( + VaultTypeMemory = "memory" + VaultTypeHashicorp = "hashicorp" +) + +// Valid values for HashicorpConfig.AuthMethod. +const ( + VaultAuthToken = "token" + VaultAuthAppRole = "approle" +) + +// Config holds the hilt service configuration. +type Config struct { + 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. +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` +} + +// AuthConfig holds authentication settings for the Tenant API. +type AuthConfig struct { + // PartnerKey is the pre-shared bearer token required on Tenant API requests. + // CSV of keys is supported, e.g. "key1,key2". + PartnerKey string `mapstructure:"partner_key"` +} + +// LogConfig holds logging settings. +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". + Type string `mapstructure:"type"` + Postgres PostgresConfig `mapstructure:"postgres"` +} + +// VaultConfig selects and configures the vault backend for private key material. +type VaultConfig struct { + // Type selects the backend: "hashicorp" or "memory". Defaults to "hashicorp". + Type string `mapstructure:"type"` + Hashicorp HashicorpConfig `mapstructure:"hashicorp"` +} + +// HashicorpConfig holds settings for the HashiCorp Vault backend. +type HashicorpConfig struct { + // Address is the Vault server address, e.g. "http://127.0.0.1:8200". + Address string `mapstructure:"address"` + // Mount is the KV v2 secrets engine mount path. Defaults to "secret". + Mount string `mapstructure:"mount"` + // AuthMethod selects how to authenticate: "token" or "approle". Defaults to + // "approle". + AuthMethod string `mapstructure:"auth_method"` + // Token is the Vault auth token (used when AuthMethod is "token"). + Token string `mapstructure:"token"` + // AppRole holds the AppRole credentials (used when AuthMethod is "approle"). + AppRole AppRoleConfig `mapstructure:"approle"` +} + +// AppRoleConfig holds HashiCorp Vault AppRole authentication credentials. +type AppRoleConfig struct { + // RoleID is the AppRole role ID. + RoleID string `mapstructure:"role_id"` + // SecretID is the AppRole secret ID. + SecretID string `mapstructure:"secret_id"` + // Mount is the AppRole auth method mount path. Defaults to "approle". + Mount string `mapstructure:"mount"` +} + +// PostgresConfig holds PostgreSQL settings. +type PostgresConfig struct { + // DSN is a libpq-style connection string, e.g. + // "postgres://user:pass@host:5432/db?sslmode=disable". + DSN string `mapstructure:"dsn"` + // MaxConns is the maximum number of connections the pool will hold. + MaxConns int32 `mapstructure:"max_conns"` + // MinConns is the minimum number of idle connections the pool maintains. + MinConns int32 `mapstructure:"min_conns"` + // SkipMigrations disables automatic goose migrations on startup. + SkipMigrations bool `mapstructure:"skip_migrations"` +} + +// SetDefaults configures default values for viper. +func SetDefaults(v *viper.Viper) { + v.SetDefault("server.host", "127.0.0.1") + v.SetDefault("server.port", 8080) + v.SetDefault("log.level", "info") + + v.SetDefault("storage.type", StorageTypePostgres) + v.SetDefault("storage.postgres.dsn", "postgres://hilt:hilt@localhost:5432/hilt?sslmode=disable") + v.SetDefault("storage.postgres.max_conns", 10) + v.SetDefault("storage.postgres.min_conns", 0) + + v.SetDefault("vault.type", VaultTypeHashicorp) + v.SetDefault("vault.hashicorp.address", "http://127.0.0.1:8200") + 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. +func BindEnvVars(v *viper.Viper) { + v.SetEnvPrefix("HILT") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() +} + +// BindFlags binds known server flags from the flag set to their config keys, so +// a flag set on the command line overrides env vars, the config file, and +// 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", + "storage.postgres.dsn": "postgres-dsn", + "storage.postgres.skip_migrations": "skip-migrations", + "vault.type": "vault", + "vault.hashicorp.address": "hashicorp-address", + "vault.hashicorp.mount": "hashicorp-mount", + "vault.hashicorp.auth_method": "hashicorp-auth-method", + "vault.hashicorp.token": "hashicorp-token", + "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 { + if f := flags.Lookup(name); f != nil { + if err := v.BindPFlag(key, f); err != nil { + return fmt.Errorf("binding flag %q: %w", name, err) + } + } + } + return nil +} + +// LoadOption customizes how configuration is loaded. +type LoadOption func(*viper.Viper) error + +// WithFlagSet binds command-line flags (see [BindFlags]) to the config so they +// take precedence over env vars, the config file, and defaults. +func WithFlagSet(flags *pflag.FlagSet) LoadOption { + return func(v *viper.Viper) error { + return BindFlags(v, flags) + } +} + +// Load creates a viper instance and loads configuration from the given config +// file (if provided), environment variables, and defaults. +func Load(configFile string, opts ...LoadOption) (*Config, error) { + cfg, _, err := LoadWithViper(configFile, opts...) + return cfg, err +} + +// LoadWithViper creates a viper instance and loads configuration, returning both +// the Config struct and the viper instance for flag binding. +func LoadWithViper(configFile string, opts ...LoadOption) (*Config, *viper.Viper, error) { + v := viper.New() + + SetDefaults(v) + BindEnvVars(v) + for _, opt := range opts { + if err := opt(v); err != nil { + return nil, nil, err + } + } + + if configFile != "" { + v.SetConfigFile(configFile) + if err := v.ReadInConfig(); err != nil { + return nil, nil, fmt.Errorf("reading config file: %w", err) + } + } else { + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(".") + v.AddConfigPath("/etc/hilt/") + // Ignore error if no config file found - use defaults and env vars. + if err := v.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, nil, fmt.Errorf("reading config file: %w", err) + } + } + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, nil, fmt.Errorf("unmarshaling config: %w", err) + } + + return &cfg, v, nil +} diff --git a/pkg/echo/middleware/logger.go b/pkg/echo/middleware/logger.go new file mode 100644 index 0000000..395ab6e --- /dev/null +++ b/pkg/echo/middleware/logger.go @@ -0,0 +1,51 @@ +package middleware + +import ( + "net/http" + + "github.com/labstack/echo/v4" + echomiddleware "github.com/labstack/echo/v4/middleware" + "go.uber.org/zap" +) + +func RequestLogger(logger *zap.Logger) echo.MiddlewareFunc { + return echomiddleware.RequestLoggerWithConfig(echomiddleware.RequestLoggerConfig{ + LogMethod: true, + LogLatency: true, + LogRemoteIP: true, + LogHost: true, + LogURI: true, + LogUserAgent: true, + LogStatus: true, + LogContentLength: true, + LogResponseSize: true, + LogHeaders: []string{}, + LogError: true, + LogValuesFunc: func(c echo.Context, v echomiddleware.RequestLoggerValues) error { + fields := []zap.Field{ + zap.Int("status", v.Status), + zap.String("method", v.Method), + zap.String("uri", v.URI), + zap.String("host", v.Host), + zap.String("remote_ip", v.RemoteIP), + zap.Duration("latency", v.Latency), + zap.String("user_agent", v.UserAgent), + zap.String("content_length", v.ContentLength), + zap.Int64("response_size", v.ResponseSize), + zap.Reflect("headers", v.Headers), + } + if v.Error != nil { + fields = append(fields, zap.Error(v.Error)) + } + switch { + case v.Status >= http.StatusInternalServerError: + logger.WithOptions(zap.Fields(fields...)).Error("server error") + case v.Status >= http.StatusBadRequest: + logger.WithOptions(zap.Fields(fields...)).Warn("client error") + default: + logger.WithOptions(zap.Fields(fields...)).Info("request completed") + } + return nil + }, + }) +} diff --git a/pkg/echo/middleware/partnerkey.go b/pkg/echo/middleware/partnerkey.go new file mode 100644 index 0000000..7e050e9 --- /dev/null +++ b/pkg/echo/middleware/partnerkey.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + "go.uber.org/zap" +) + +const bearerPrefix = "Bearer " + +// PartnerKeyAuth returns echo middleware that requires requests to carry the +// configured partner key as an HTTP bearer token +// (Authorization: Bearer ). It responds 401 when the header is +// missing, malformed, or does not match. If partnerKey is empty (unconfigured), +// it fails closed and rejects all requests. The key and presented token are +// never logged. +func PartnerKeyAuth(partnerKey []string, logger *zap.Logger) echo.MiddlewareFunc { + // normalize the key set + var keys []string + for _, k := range partnerKey { + if strings.TrimSpace(k) != "" { + keys = append(keys, k) + } + } + + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if len(keys) == 0 { + logger.Warn("partner key not configured; rejecting request") + return unauthorized(c) + } + + auth := c.Request().Header.Get(echo.HeaderAuthorization) + if len(auth) <= len(bearerPrefix) || !strings.EqualFold(auth[:len(bearerPrefix)], bearerPrefix) { + logger.Debug("missing or malformed Authorization header") + return unauthorized(c) + } + token := auth[len(bearerPrefix):] + + valid := false + for _, key := range keys { + if subtle.ConstantTimeCompare([]byte(token), []byte(key)) == 1 { + valid = true + break + } + } + if !valid { + logger.Debug("partner key mismatch") + return unauthorized(c) + } + + return next(c) + } + } +} + +func unauthorized(c echo.Context) error { + c.Response().Header().Set(echo.HeaderWWWAuthenticate, "Bearer") + return echo.NewHTTPError(http.StatusUnauthorized, "invalid or missing partner key") +} diff --git a/pkg/echo/middleware/partnerkey_test.go b/pkg/echo/middleware/partnerkey_test.go new file mode 100644 index 0000000..8dc8912 --- /dev/null +++ b/pkg/echo/middleware/partnerkey_test.go @@ -0,0 +1,70 @@ +package middleware_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/fil-forge/hilt/pkg/echo/middleware" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +// newServer builds an echo server whose /guarded route is protected by the +// partner-key middleware (returning 200 when auth passes). +func newServer(partnerKey []string) *echo.Echo { + e := echo.New() + g := e.Group("", middleware.PartnerKeyAuth(partnerKey, zap.NewNop())) + g.GET("/guarded", func(c echo.Context) error { + return c.String(http.StatusOK, "ok") + }) + return e +} + +func do(t *testing.T, e *echo.Echo, authHeader string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/guarded", nil) + if authHeader != "" { + req.Header.Set(echo.HeaderAuthorization, authHeader) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +func TestPartnerKeyAuth(t *testing.T) { + const key = "s3cr3t-partner-key" + + t.Run("correct bearer passes", func(t *testing.T) { + rec := do(t, newServer([]string{key}), "Bearer "+key) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "ok", rec.Body.String()) + }) + + t.Run("wrong bearer is rejected", func(t *testing.T) { + rec := do(t, newServer([]string{key}), "Bearer wrong") + require.Equal(t, http.StatusUnauthorized, rec.Code) + require.Equal(t, "Bearer", rec.Header().Get(echo.HeaderWWWAuthenticate)) + }) + + t.Run("missing header is rejected", func(t *testing.T) { + rec := do(t, newServer([]string{key}), "") + require.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("non-bearer scheme is rejected", func(t *testing.T) { + rec := do(t, newServer([]string{key}), "Basic "+key) + require.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("unconfigured key rejects all", func(t *testing.T) { + rec := do(t, newServer([]string{}), "Bearer ") + require.Equal(t, http.StatusUnauthorized, rec.Code) + }) + + t.Run("empty/whitespace key rejects all", func(t *testing.T) { + rec := do(t, newServer([]string{"", " ", "\t"}), "Bearer ") + require.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} diff --git a/pkg/fx/api.go b/pkg/fx/api.go new file mode 100644 index 0000000..81b5d14 --- /dev/null +++ b/pkg/fx/api.go @@ -0,0 +1,29 @@ +package fx + +import ( + "github.com/fil-forge/hilt/pkg/api" + "go.uber.org/fx" +) + +// APIModule provides the tenant management API handlers as routes, collected +// into the "routes" group and registered on the echo server. +var APIModule = fx.Module("api", + fx.Provide( + // Tenants + asRoute(api.NewProvisionTenantHandler), + asRoute(api.NewGetTenantHandler), + asRoute(api.NewDeleteTenantHandler), + asRoute(api.NewUpdateTenantStatusHandler), + // Access Keys + asRoute(api.NewCreateAccessKeyHandler), + asRoute(api.NewListAccessKeysHandler), + asRoute(api.NewGetAccessKeyHandler), + asRoute(api.NewDeleteAccessKeyHandler), + ), +) + +// asRoute annotates a handler constructor so its result joins the "routes" +// group consumed by the echo server. +func asRoute(constructor any) any { + return fx.Annotate(constructor, fx.ResultTags(`group:"routes"`)) +} diff --git a/pkg/fx/app.go b/pkg/fx/app.go new file mode 100644 index 0000000..c4711f8 --- /dev/null +++ b/pkg/fx/app.go @@ -0,0 +1,49 @@ +package fx + +import ( + "fmt" + + "github.com/fil-forge/hilt/pkg/config" + storememory "github.com/fil-forge/hilt/pkg/fx/store/memory" + storepostgres "github.com/fil-forge/hilt/pkg/fx/store/postgres" + vaulthashicorp "github.com/fil-forge/hilt/pkg/fx/vault/hashicorp" + vaultmemory "github.com/fil-forge/hilt/pkg/fx/vault/memory" + "go.uber.org/fx" +) + +// AppModule aggregates all application modules into a single fx option, +// selecting the storage backend from the configured storage type. +func AppModule(cfg *config.Config) fx.Option { + opts := []fx.Option{ + fx.Supply(cfg), + ConfigModule, + LoggerModule, + IdentityModule, + PLCModule, + APIModule, + RPCModule, + ServerModule, + } + + switch cfg.Storage.Type { + case config.StorageTypeMemory: + opts = append(opts, storememory.Module) + case config.StorageTypePostgres, "": + // Empty type is treated as the default backend (postgres). + opts = append(opts, storepostgres.Module) + default: + return fx.Error(fmt.Errorf("unknown storage.type %q (valid: memory, postgres)", cfg.Storage.Type)) + } + + switch cfg.Vault.Type { + case config.VaultTypeMemory: + opts = append(opts, vaultmemory.Module) + case config.VaultTypeHashicorp, "": + // Empty type is treated as the default backend (hashicorp). + opts = append(opts, vaulthashicorp.Module) + default: + return fx.Error(fmt.Errorf("unknown vault.type %q (valid: memory, hashicorp)", cfg.Vault.Type)) + } + + return fx.Options(opts...) +} diff --git a/pkg/fx/app_test.go b/pkg/fx/app_test.go new file mode 100644 index 0000000..2c71e50 --- /dev/null +++ b/pkg/fx/app_test.go @@ -0,0 +1,67 @@ +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" + "go.uber.org/fx" +) + +func validate(cfg *config.Config) error { + return fx.ValidateApp(appfx.AppModule(cfg), fx.NopLogger) +} + +func TestAppModuleStorageSelection(t *testing.T) { + t.Run("memory", func(t *testing.T) { + cfg := &config.Config{Storage: config.StorageConfig{Type: config.StorageTypeMemory}} + require.NoError(t, validate(cfg)) + }) + + t.Run("postgres", func(t *testing.T) { + cfg := &config.Config{Storage: config.StorageConfig{ + Type: config.StorageTypePostgres, + Postgres: config.PostgresConfig{DSN: "postgres://hilt:hilt@localhost:5432/hilt?sslmode=disable"}, + }} + require.NoError(t, validate(cfg)) + }) + + t.Run("empty defaults to postgres", func(t *testing.T) { + cfg := &config.Config{Storage: config.StorageConfig{ + Type: "", + Postgres: config.PostgresConfig{DSN: "postgres://hilt:hilt@localhost:5432/hilt?sslmode=disable"}, + }} + require.NoError(t, validate(cfg)) + }) + + t.Run("unknown type errors", func(t *testing.T) { + cfg := &config.Config{Storage: config.StorageConfig{Type: "bogus"}} + require.Error(t, validate(cfg)) + }) +} + +func TestAppModuleVaultSelection(t *testing.T) { + cfg := func(vaultType string) *config.Config { + return &config.Config{ + Storage: config.StorageConfig{Type: config.StorageTypeMemory}, + Vault: config.VaultConfig{Type: vaultType}, + } + } + + t.Run("memory", func(t *testing.T) { + require.NoError(t, validate(cfg(config.VaultTypeMemory))) + }) + + t.Run("empty defaults to hashicorp", func(t *testing.T) { + require.NoError(t, validate(cfg(""))) + }) + + t.Run("hashicorp", func(t *testing.T) { + require.NoError(t, validate(cfg(config.VaultTypeHashicorp))) + }) + + t.Run("unknown type errors", func(t *testing.T) { + require.Error(t, validate(cfg("bogus"))) + }) +} diff --git a/pkg/fx/config.go b/pkg/fx/config.go new file mode 100644 index 0000000..3812604 --- /dev/null +++ b/pkg/fx/config.go @@ -0,0 +1,41 @@ +package fx + +import ( + "github.com/fil-forge/hilt/pkg/config" + "go.uber.org/fx" +) + +// ConfigModule surfaces the individual config sections so consumers can depend +// on just the part they need. +var ConfigModule = fx.Module("config", + fx.Provide(ProvideConfigs), +) + +// Configs exposes the individual fields of the config to the fx graph. +type Configs struct { + fx.Out + 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/logger.go b/pkg/fx/logger.go new file mode 100644 index 0000000..7fd037e --- /dev/null +++ b/pkg/fx/logger.go @@ -0,0 +1,36 @@ +package fx + +import ( + "fmt" + + "github.com/fil-forge/hilt/pkg/config" + "go.uber.org/fx" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// LoggerModule provides the zap logger. +var LoggerModule = fx.Module("logger", fx.Provide(NewLogger)) + +// NewLogger creates a zap logger based on the configured log level. +func NewLogger(cfg config.LogConfig) (*zap.Logger, error) { + var level zapcore.Level + err := level.UnmarshalText([]byte(cfg.Level)) + if err != nil { + return nil, fmt.Errorf("parsing log level: %w", err) + } + + var logCfg zap.Config + if level == zapcore.DebugLevel { + logCfg = zap.NewDevelopmentConfig() + } else { + logCfg = zap.NewProductionConfig() + } + logCfg.Level = zap.NewAtomicLevelAt(level) + + logger, err := logCfg.Build() + if err != nil { + return nil, fmt.Errorf("creating logger: %w", err) + } + return logger, 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 new file mode 100644 index 0000000..edf1ca8 --- /dev/null +++ b/pkg/fx/server.go @@ -0,0 +1,116 @@ +package fx + +import ( + "context" + "fmt" + "net" + "net/http" + "strings" + + "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" + "go.uber.org/zap" +) + +// ServerModule provides the HTTP server with lifecycle management. +var ServerModule = fx.Module("server", + fx.Provide(NewEchoServer), + fx.Invoke(RegisterServerLifecycle), +) + +// ServerParams are the dependencies for constructing the echo server. Routes +// are collected from the "routes" fx group (see APIModule). +type ServerParams struct { + fx.In + 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. +func NewEchoServer(p ServerParams) *echo.Echo { + e := echo.New() + e.HideBanner = true + e.HidePort = true + + e.Use(echomiddleware.Recover()) + e.Use(middleware.RequestLogger(p.Logger)) + + e.GET("/", func(c echo.Context) error { + return c.String(http.StatusOK, "hello world") + }) + 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(strings.Split(p.Auth.PartnerKey, ","), p.Logger)) + for _, r := range p.Routes { + api.Add(r.Method, r.Path, r.Handler) + } + + return e +} + +// RegisterServerLifecycle hooks server start/stop to the fx lifecycle. +func RegisterServerLifecycle( + lc fx.Lifecycle, + e *echo.Echo, + cfg config.ServerConfig, + logger *zap.Logger, +) { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + + // Bind synchronously so a failure (e.g. port already in use) is + // returned from OnStart and aborts fx startup. + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("binding %s: %w", addr, err) + } + e.Listener = ln + logger.Info("starting Hilt service", zap.String("address", addr)) + go func() { + // e.Start reuses the listener bound above and blocks serving + // until Shutdown, which returns http.ErrServerClosed. + if err := e.Start(addr); err != nil && err != http.ErrServerClosed { + logger.Error("server stopped unexpectedly", zap.Error(err)) + } + }() + + return nil + }, + OnStop: func(ctx context.Context) error { + logger.Info("shutting down server") + return e.Shutdown(ctx) + }, + }) +} + +// 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/fx/store/memory/provider.go b/pkg/fx/store/memory/provider.go new file mode 100644 index 0000000..96a6d60 --- /dev/null +++ b/pkg/fx/store/memory/provider.go @@ -0,0 +1,28 @@ +// Package memory wires the in-memory store implementations into the +// application via uber-go/fx. +package memory + +import ( + "github.com/fil-forge/hilt/pkg/store/accesskey" + memaccesskey "github.com/fil-forge/hilt/pkg/store/accesskey/memory" + "github.com/fil-forge/hilt/pkg/store/bucket" + membucket "github.com/fil-forge/hilt/pkg/store/bucket/memory" + "github.com/fil-forge/hilt/pkg/store/delegation" + memdelegation "github.com/fil-forge/hilt/pkg/store/delegation/memory" + "github.com/fil-forge/hilt/pkg/store/provider" + memprovider "github.com/fil-forge/hilt/pkg/store/provider/memory" + "github.com/fil-forge/hilt/pkg/store/tenant" + memtenant "github.com/fil-forge/hilt/pkg/store/tenant/memory" + "go.uber.org/fx" +) + +// Module provides the in-memory store implementations. +var Module = fx.Module("memory-store", + fx.Provide( + fx.Annotate(memaccesskey.New, fx.As(new(accesskey.Store))), + fx.Annotate(membucket.New, fx.As(new(bucket.Store))), + fx.Annotate(memdelegation.New, fx.As(new(delegation.Store))), + fx.Annotate(memprovider.New, fx.As(new(provider.Store))), + fx.Annotate(memtenant.New, fx.As(new(tenant.Store))), + ), +) diff --git a/pkg/fx/store/postgres/provider.go b/pkg/fx/store/postgres/provider.go new file mode 100644 index 0000000..766d36f --- /dev/null +++ b/pkg/fx/store/postgres/provider.go @@ -0,0 +1,123 @@ +// Package postgres wires the Postgres-backed store implementations into the +// application via uber-go/fx. +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/fil-forge/hilt/pkg/config" + "github.com/fil-forge/hilt/pkg/migrations" + "github.com/fil-forge/hilt/pkg/store/accesskey" + pgaccesskey "github.com/fil-forge/hilt/pkg/store/accesskey/postgres" + "github.com/fil-forge/hilt/pkg/store/bucket" + pgbucket "github.com/fil-forge/hilt/pkg/store/bucket/postgres" + "github.com/fil-forge/hilt/pkg/store/delegation" + pgdelegation "github.com/fil-forge/hilt/pkg/store/delegation/postgres" + "github.com/fil-forge/hilt/pkg/store/provider" + pgprovider "github.com/fil-forge/hilt/pkg/store/provider/postgres" + "github.com/fil-forge/hilt/pkg/store/tenant" + pgtenant "github.com/fil-forge/hilt/pkg/store/tenant/postgres" + "github.com/jackc/pgx/v5/pgxpool" + "go.uber.org/fx" + "go.uber.org/zap" +) + +// Module provides the Postgres-backed store implementations. +var Module = fx.Module("postgres-store", + fx.Provide( + NewPostgresPool, + NewMigratedPool, + NewAccessKeyStore, + NewBucketStore, + NewDelegationStore, + NewProviderStore, + NewTenantStore, + ), +) + +// MigratedPool is a *pgxpool.Pool whose schema is guaranteed to be at the head +// migration revision by the time any store that depends on it is used. Store +// constructors depend on *MigratedPool rather than *pgxpool.Pool so the fx +// dependency graph orders NewMigratedPool's migration hook before the stores. +type MigratedPool struct { + *pgxpool.Pool +} + +// NewPostgresPool creates a pgx connection pool and registers a lifecycle hook +// to ping on start and close it at shutdown. +func NewPostgresPool(cfg config.PostgresConfig, lc fx.Lifecycle, logger *zap.Logger) (*pgxpool.Pool, error) { + if cfg.DSN == "" { + return nil, errors.New("storage.postgres.dsn is required when storage.type is \"postgres\"") + } + + poolCfg, err := pgxpool.ParseConfig(cfg.DSN) + if err != nil { + return nil, fmt.Errorf("parsing postgres DSN: %w", err) + } + if cfg.MaxConns > 0 { + poolCfg.MaxConns = cfg.MaxConns + } + if cfg.MinConns > 0 { + poolCfg.MinConns = cfg.MinConns + } + + pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg) + if err != nil { + return nil, fmt.Errorf("creating pgx pool: %w", err) + } + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + if err := pool.Ping(ctx); err != nil { + return fmt.Errorf("pinging postgres: %w", err) + } + logger.Info("connected to postgres", zap.Int32("max_conns", poolCfg.MaxConns)) + return nil + }, + OnStop: func(ctx context.Context) error { + pool.Close() + return nil + }, + }) + + return pool, nil +} + +// NewMigratedPool registers an OnStart hook that runs goose migrations against +// the pool (unless storage.postgres.skip_migrations is true) and returns a +// *MigratedPool wrapper that store constructors depend on. +func NewMigratedPool(lc fx.Lifecycle, cfg config.PostgresConfig, pool *pgxpool.Pool, logger *zap.Logger) *MigratedPool { + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + if cfg.SkipMigrations { + logger.Warn("skipping postgres migrations (storage.postgres.skip_migrations=true)") + return nil + } + logger.Info("running postgres migrations") + return migrations.Up(ctx, pool, logger) + }, + }) + return &MigratedPool{Pool: pool} +} + +func NewAccessKeyStore(mdb *MigratedPool) accesskey.Store { + return pgaccesskey.New(mdb.Pool) +} + +func NewBucketStore(mdb *MigratedPool) bucket.Store { + return pgbucket.New(mdb.Pool) +} + +func NewDelegationStore(mdb *MigratedPool) delegation.Store { + return pgdelegation.New(mdb.Pool) +} + +func NewProviderStore(mdb *MigratedPool) provider.Store { + return pgprovider.New(mdb.Pool) +} + +func NewTenantStore(mdb *MigratedPool) tenant.Store { + return pgtenant.New(mdb.Pool) +} diff --git a/pkg/fx/vault/hashicorp/provider.go b/pkg/fx/vault/hashicorp/provider.go new file mode 100644 index 0000000..db6db8b --- /dev/null +++ b/pkg/fx/vault/hashicorp/provider.go @@ -0,0 +1,69 @@ +// Package hashicorp wires the HashiCorp Vault-backed vault implementation into +// the application via uber-go/fx. +package hashicorp + +import ( + "context" + "fmt" + + "github.com/fil-forge/hilt/pkg/config" + hiltvault "github.com/fil-forge/hilt/pkg/vault" + vaulthashicorp "github.com/fil-forge/hilt/pkg/vault/hashicorp" + vaultclient "github.com/hashicorp/vault-client-go" + "go.uber.org/fx" +) + +// Module provides the HashiCorp Vault-backed vault implementation. +var Module = fx.Module("hashicorp-vault", + fx.Provide(NewVault), +) + +// NewVault builds a HashiCorp Vault-backed vault from configuration and +// authenticates the client on startup (via token or AppRole). +func NewVault(cfg config.HashicorpConfig, lc fx.Lifecycle) (hiltvault.Vault, error) { + if cfg.Address == "" { + return nil, fmt.Errorf("vault.hashicorp.address is required when vault.type is %q", config.VaultTypeHashicorp) + } + client, err := vaultclient.New(vaultclient.WithAddress(cfg.Address)) + if err != nil { + return nil, fmt.Errorf("creating vault client: %w", err) + } + mount := cfg.Mount + if mount == "" { + mount = "secret" + } + + // Authenticate on start so network/auth happens at startup rather than at + // construction (mirrors the postgres pool lifecycle). + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return authenticate(ctx, client, cfg) + }, + }) + + return vaulthashicorp.New(client, mount), nil +} + +func authenticate(ctx context.Context, client *vaultclient.Client, cfg config.HashicorpConfig) error { + switch cfg.AuthMethod { + case config.VaultAuthToken, "": + if cfg.Token == "" { + return fmt.Errorf("vault.hashicorp.token is required when vault.hashicorp.auth_method is %q", config.VaultAuthToken) + } + if err := client.SetToken(cfg.Token); err != nil { + return fmt.Errorf("setting vault token: %w", err) + } + return nil + case config.VaultAuthAppRole: + if cfg.AppRole.RoleID == "" || cfg.AppRole.SecretID == "" { + return fmt.Errorf("vault.hashicorp.approle.role_id and secret_id are required when vault.hashicorp.auth_method is %q", config.VaultAuthAppRole) + } + mount := cfg.AppRole.Mount + if mount == "" { + mount = "approle" + } + return vaulthashicorp.AppRoleLogin(ctx, client, mount, cfg.AppRole.RoleID, cfg.AppRole.SecretID) + default: + return fmt.Errorf("unknown vault.hashicorp.auth_method %q (valid: token, approle)", cfg.AuthMethod) + } +} diff --git a/pkg/fx/vault/memory/provider.go b/pkg/fx/vault/memory/provider.go new file mode 100644 index 0000000..ca3d2f2 --- /dev/null +++ b/pkg/fx/vault/memory/provider.go @@ -0,0 +1,16 @@ +// Package memory wires the in-memory vault implementation into the application +// via uber-go/fx. +package memory + +import ( + "github.com/fil-forge/hilt/pkg/vault" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + "go.uber.org/fx" +) + +// Module provides the in-memory vault implementation. +var Module = fx.Module("memory-vault", + fx.Provide( + fx.Annotate(vaultmemory.New, fx.As(new(vault.Vault))), + ), +) 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 d1d8a91..884df17 100644 --- a/pkg/store/accesskey/postgres/store.go +++ b/pkg/store/accesskey/postgres/store.go @@ -10,13 +10,12 @@ import ( "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/accesskey" "github.com/fil-forge/ucantone/did" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) -const uniqueViolation = "23505" - type Store struct { pool *pgxpool.Pool } @@ -30,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() @@ -38,13 +37,18 @@ 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 == uniqueViolation { + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { return store.ErrRecordExists } return fmt.Errorf("adding access key: %w", err) @@ -54,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 256b0a2..99d65ca 100644 --- a/pkg/store/bucket/postgres/store.go +++ b/pkg/store/bucket/postgres/store.go @@ -10,13 +10,12 @@ import ( "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/bucket" "github.com/fil-forge/ucantone/did" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) -const uniqueViolation = "23505" - type Store struct { pool *pgxpool.Pool } @@ -37,7 +36,7 @@ func (s *Store) Add(ctx context.Context, id did.DID, tenant did.DID, name string `, id.String(), tenant.String(), name, time.Now().UTC()) if err != nil { var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation { + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { return store.ErrRecordExists } return fmt.Errorf("adding bucket: %w", err) @@ -61,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/provider/postgres/store.go b/pkg/store/provider/postgres/store.go index 76aafc6..8f6e6b6 100644 --- a/pkg/store/provider/postgres/store.go +++ b/pkg/store/provider/postgres/store.go @@ -10,13 +10,12 @@ import ( "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/provider" "github.com/fil-forge/ucantone/did" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) -const uniqueViolation = "23505" - type Store struct { pool *pgxpool.Pool } @@ -37,7 +36,7 @@ func (s *Store) Add(ctx context.Context, id did.DID, region string) error { `, id.String(), region, time.Now().UTC()) if err != nil { var pgErr *pgconn.PgError - if errors.As(err, &pgErr) && pgErr.Code == uniqueViolation { + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { return store.ErrRecordExists } return fmt.Errorf("adding provider: %w", err) 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 eb518c9..c902f67 100644 --- a/pkg/store/tenant/postgres/store.go +++ b/pkg/store/tenant/postgres/store.go @@ -10,13 +10,12 @@ import ( "github.com/fil-forge/hilt/pkg/store" "github.com/fil-forge/hilt/pkg/store/tenant" "github.com/fil-forge/ucantone/did" + "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) -const uniqueViolation = "23505" - type Store struct { pool *pgxpool.Pool } @@ -30,14 +29,14 @@ 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 == uniqueViolation { + if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { return store.ErrRecordExists } return fmt.Errorf("adding tenant: %w", err) @@ -47,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()) @@ -61,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 @@ -76,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 } @@ -83,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) @@ -101,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)) + }) }) } } diff --git a/pkg/vault/errors.go b/pkg/vault/errors.go new file mode 100644 index 0000000..66e129e --- /dev/null +++ b/pkg/vault/errors.go @@ -0,0 +1,10 @@ +package vault + +import "github.com/fil-forge/ucantone/errors" + +// KeyNotFoundErrorName is the name given to an error where the key is not found +// in the vault. +const KeyNotFoundErrorName = "KeyNotFound" + +// ErrNotFound is returned when no value exists for a key. +var ErrNotFound = errors.New(KeyNotFoundErrorName, "key not found") diff --git a/pkg/vault/hashicorp/approle_test.go b/pkg/vault/hashicorp/approle_test.go new file mode 100644 index 0000000..4c3328a --- /dev/null +++ b/pkg/vault/hashicorp/approle_test.go @@ -0,0 +1,97 @@ +package hashicorp_test + +import ( + "context" + "runtime" + "testing" + + "github.com/fil-forge/hilt/internal/testutil" + "github.com/fil-forge/hilt/pkg/vault" + vaulthashicorp "github.com/fil-forge/hilt/pkg/vault/hashicorp" + vaultclient "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" + "github.com/stretchr/testify/require" +) + +const appRolePolicy = `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }` + +// setupAppRole enables the AppRole auth method on a dev Vault, creates a role +// bound to a policy granting access to secret/*, and returns the role's +// role_id and a fresh secret_id. +func setupAppRole(t *testing.T, address, rootToken string) (roleID, secretID string) { + t.Helper() + ctx := t.Context() + + admin, err := vaultclient.New(vaultclient.WithAddress(address)) + require.NoError(t, err) + require.NoError(t, admin.SetToken(rootToken)) + + _, err = admin.System.AuthEnableMethod(ctx, "approle", schema.AuthEnableMethodRequest{Type: "approle"}) + require.NoError(t, err) + + _, err = admin.System.PoliciesWriteAclPolicy(ctx, "hilt", schema.PoliciesWriteAclPolicyRequest{Policy: appRolePolicy}) + require.NoError(t, err) + + _, err = admin.Auth.AppRoleWriteRole(ctx, "hilt", schema.AppRoleWriteRoleRequest{ + TokenPolicies: []string{"hilt"}, + }) + require.NoError(t, err) + + roleResp, err := admin.Auth.AppRoleReadRoleId(ctx, "hilt") + require.NoError(t, err) + require.NotEmpty(t, roleResp.Data.RoleId) + + // Use the generic Write rather than the typed AppRoleWriteSecretId: the + // v0.4.3 typed response models secret_id_ttl as a string but Vault returns a + // number, which fails to unmarshal. + secretResp, err := admin.Write(ctx, "auth/approle/role/hilt/secret-id", nil) + require.NoError(t, err) + secretID, ok := secretResp.Data["secret_id"].(string) + require.True(t, ok, "secret_id missing from response") + require.NotEmpty(t, secretID) + + return roleResp.Data.RoleId, secretID +} + +func TestAppRoleLogin(t *testing.T) { + if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !testutil.IsDockerAvailable(t) { + t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") + } + } + if !testutil.IsDockerAvailable(t) { + t.SkipNow() + } + + address, rootToken := testutil.CreateVault(t) + roleID, secretID := setupAppRole(t, address, rootToken) + + t.Run("logs in and yields a usable token", func(t *testing.T) { + client, err := vaultclient.New(vaultclient.WithAddress(address)) + require.NoError(t, err) + + require.NoError(t, vaulthashicorp.AppRoleLogin(t.Context(), client, "approle", roleID, secretID)) + + // The issued token must be able to read/write the KV engine. + store := vaulthashicorp.New(client, "secret") + require.NoError(t, store.Write(t.Context(), "/tenant/alice", []byte("secret"))) + got, err := store.Read(t.Context(), "/tenant/alice") + require.NoError(t, err) + require.Equal(t, []byte("secret"), got) + }) + + t.Run("fails with a bogus secret id", func(t *testing.T) { + client, err := vaultclient.New(vaultclient.WithAddress(address)) + require.NoError(t, err) + + err = vaulthashicorp.AppRoleLogin(context.Background(), client, "approle", roleID, "not-a-real-secret-id") + require.Error(t, err) + }) + + t.Run("login result satisfies the Vault interface", func(t *testing.T) { + client, err := vaultclient.New(vaultclient.WithAddress(address)) + require.NoError(t, err) + require.NoError(t, vaulthashicorp.AppRoleLogin(t.Context(), client, "approle", roleID, secretID)) + var _ vault.Vault = vaulthashicorp.New(client, "secret") + }) +} diff --git a/pkg/vault/hashicorp/auth.go b/pkg/vault/hashicorp/auth.go new file mode 100644 index 0000000..1a2d73d --- /dev/null +++ b/pkg/vault/hashicorp/auth.go @@ -0,0 +1,30 @@ +package hashicorp + +import ( + "context" + "fmt" + + vaultclient "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" +) + +// AppRoleLogin authenticates the client against the AppRole auth method mounted +// at authMount using the given role and secret IDs, and sets the issued token +// on the client for subsequent requests. Role/secret IDs and the issued token +// are never logged. +func AppRoleLogin(ctx context.Context, client *vaultclient.Client, authMount, roleID, secretID string) error { + resp, err := client.Auth.AppRoleLogin(ctx, schema.AppRoleLoginRequest{ + RoleId: roleID, + SecretId: secretID, + }, vaultclient.WithMountPath(authMount)) + if err != nil { + return fmt.Errorf("approle login: %w", err) + } + if resp.Auth == nil || resp.Auth.ClientToken == "" { + return fmt.Errorf("approle login returned no client token") + } + if err := client.SetToken(resp.Auth.ClientToken); err != nil { + return fmt.Errorf("setting client token: %w", err) + } + return nil +} diff --git a/pkg/vault/hashicorp/vault.go b/pkg/vault/hashicorp/vault.go new file mode 100644 index 0000000..cc36f95 --- /dev/null +++ b/pkg/vault/hashicorp/vault.go @@ -0,0 +1,89 @@ +// Package hashicorp provides a HashiCorp Vault (KV v2) backed implementation of +// vault.Vault, using github.com/hashicorp/vault-client-go. +package hashicorp + +import ( + "context" + "encoding/base64" + "fmt" + "net/http" + "strings" + + hiltvault "github.com/fil-forge/hilt/pkg/vault" + vaultclient "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" +) + +// dataKey is the field within a KV v2 secret under which the (base64-encoded) +// value bytes are stored. KV v2 data is JSON, so binary key material is +// base64-encoded. +const dataKey = "value" + +// Store is a vault.Vault backed by a HashiCorp Vault KV v2 secrets engine. +type Store struct { + client *vaultclient.Client + mount string +} + +var _ hiltvault.Vault = (*Store)(nil) + +// New returns a Store that stores secrets in the KV v2 engine mounted at mount +// (e.g. "secret") using the given client. +func New(client *vaultclient.Client, mount string) *Store { + return &Store{client: client, mount: mount} +} + +func (s *Store) Read(ctx context.Context, key string) ([]byte, error) { + resp, err := s.client.Secrets.KvV2Read(ctx, secretPath(key), vaultclient.WithMountPath(s.mount)) + if err != nil { + if vaultclient.IsErrorStatus(err, http.StatusNotFound) { + return nil, hiltvault.ErrNotFound + } + return nil, fmt.Errorf("reading secret: %w", err) + } + // A soft-deleted secret reads back with nil data. + if resp.Data.Data == nil { + return nil, hiltvault.ErrNotFound + } + encoded, ok := resp.Data.Data[dataKey].(string) + if !ok { + return nil, fmt.Errorf("secret missing %q field", dataKey) + } + value, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decoding secret value: %w", err) + } + return value, nil +} + +func (s *Store) Write(ctx context.Context, key string, value []byte) error { + _, err := s.client.Secrets.KvV2Write(ctx, secretPath(key), schema.KvV2WriteRequest{ + Data: map[string]any{ + dataKey: base64.StdEncoding.EncodeToString(value), + }, + }, vaultclient.WithMountPath(s.mount)) + if err != nil { + return fmt.Errorf("writing secret: %w", err) + } + return nil +} + +func (s *Store) Delete(ctx context.Context, key string) error { + // Permanently remove the secret (all versions + metadata). Idempotent: a + // missing secret is not an error. + _, err := s.client.Secrets.KvV2DeleteMetadataAndAllVersions(ctx, secretPath(key), vaultclient.WithMountPath(s.mount)) + if err != nil { + if vaultclient.IsErrorStatus(err, http.StatusNotFound) { + return nil + } + return fmt.Errorf("deleting secret: %w", err) + } + return nil +} + +// secretPath normalizes a vault key into a KV v2 secret path. Hilt keys are +// path-like (e.g. "/tenant/{id}"); a leading slash would create an empty path +// segment in the Vault API URL, so it is trimmed. +func secretPath(key string) string { + return strings.TrimPrefix(key, "/") +} diff --git a/pkg/vault/memory/vault.go b/pkg/vault/memory/vault.go new file mode 100644 index 0000000..70abec7 --- /dev/null +++ b/pkg/vault/memory/vault.go @@ -0,0 +1,51 @@ +// Package memory provides an in-memory implementation of vault.Vault. +package memory + +import ( + "context" + "slices" + "sync" + + "github.com/fil-forge/hilt/pkg/vault" +) + +type Store struct { + mutex sync.RWMutex + values map[string][]byte +} + +var _ vault.Vault = (*Store)(nil) + +func New() *Store { + return &Store{values: map[string][]byte{}} +} + +func (s *Store) Read(ctx context.Context, key string) ([]byte, error) { + s.mutex.RLock() + defer s.mutex.RUnlock() + + v, ok := s.values[key] + if !ok { + return nil, vault.ErrNotFound + } + // Return a copy so callers cannot mutate the stored secret. + return slices.Clone(v), nil +} + +func (s *Store) Write(ctx context.Context, key string, value []byte) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + // Store a copy so later mutation of the caller's slice cannot alter the + // stored secret. + s.values[key] = slices.Clone(value) + return nil +} + +func (s *Store) Delete(ctx context.Context, key string) error { + s.mutex.Lock() + defer s.mutex.Unlock() + + delete(s.values, key) + return nil +} diff --git a/pkg/vault/vault.go b/pkg/vault/vault.go new file mode 100644 index 0000000..5e764dd --- /dev/null +++ b/pkg/vault/vault.go @@ -0,0 +1,19 @@ +// Package vault defines a KMS-agnostic interface for storing the private key +// material Hilt manages (tenant keys, bucket keys, S3 access keys). Keys are +// opaque string paths and values are raw private-key bytes. Implementations may +// be backed by any KMS; an in-memory implementation lives in vault/memory. +package vault + +import "context" + +// Vault stores secret key material by opaque string key. +type Vault interface { + // Read returns the value stored at key. It returns [ErrNotFound] if no value + // exists for the key. + Read(ctx context.Context, key string) ([]byte, error) + // Write stores value at key, overwriting any existing value. + Write(ctx context.Context, key string, value []byte) error + // Delete removes the value at key. It is idempotent: deleting an absent key + // returns nil. + Delete(ctx context.Context, key string) error +} diff --git a/pkg/vault/vault_test.go b/pkg/vault/vault_test.go new file mode 100644 index 0000000..33e10f5 --- /dev/null +++ b/pkg/vault/vault_test.go @@ -0,0 +1,105 @@ +package vault_test + +import ( + "runtime" + "testing" + + "github.com/fil-forge/hilt/internal/testutil" + "github.com/fil-forge/hilt/pkg/vault" + vaulthashicorp "github.com/fil-forge/hilt/pkg/vault/hashicorp" + vaultmemory "github.com/fil-forge/hilt/pkg/vault/memory" + vaultclient "github.com/hashicorp/vault-client-go" + "github.com/stretchr/testify/require" +) + +type VaultKind string + +const ( + Memory VaultKind = "memory" + Hashicorp VaultKind = "hashicorp" +) + +var vaultKinds = []VaultKind{Memory, Hashicorp} + +func makeVault(t *testing.T, k VaultKind) vault.Vault { + switch k { + case Memory: + return vaultmemory.New() + case Hashicorp: + return createHashicorpVault(t) + } + panic("unknown vault kind") +} + +func createHashicorpVault(t *testing.T) vault.Vault { + if testutil.IsRunningInCI(t) && runtime.GOOS == "linux" { + if !testutil.IsDockerAvailable(t) { + t.Fatalf("docker is expected in CI linux testing environments, but wasn't found") + } + } + if !testutil.IsDockerAvailable(t) { + t.SkipNow() + } + address, token := testutil.CreateVault(t) + client, err := vaultclient.New(vaultclient.WithAddress(address)) + require.NoError(t, err) + require.NoError(t, client.SetToken(token)) + return vaulthashicorp.New(client, "secret") +} + +func TestVault(t *testing.T) { + for _, k := range vaultKinds { + t.Run(string(k), func(t *testing.T) { + v := makeVault(t, k) + + t.Run("writes and reads a value", func(t *testing.T) { + require.NoError(t, v.Write(t.Context(), "/tenant/alice", []byte("secret"))) + got, err := v.Read(t.Context(), "/tenant/alice") + require.NoError(t, err) + require.Equal(t, []byte("secret"), got) + }) + + t.Run("Read returns ErrNotFound for unknown key", func(t *testing.T) { + _, err := v.Read(t.Context(), "/tenant/nobody") + require.ErrorIs(t, err, vault.ErrNotFound) + }) + + t.Run("Write overwrites an existing value", func(t *testing.T) { + key := "/tenant/bob" + require.NoError(t, v.Write(t.Context(), key, []byte("first"))) + require.NoError(t, v.Write(t.Context(), key, []byte("second"))) + got, err := v.Read(t.Context(), key) + require.NoError(t, err) + require.Equal(t, []byte("second"), got) + }) + + t.Run("Delete removes a value", func(t *testing.T) { + key := "/tenant/carol" + require.NoError(t, v.Write(t.Context(), key, []byte("secret"))) + require.NoError(t, v.Delete(t.Context(), key)) + _, err := v.Read(t.Context(), key) + require.ErrorIs(t, err, vault.ErrNotFound) + }) + + t.Run("Delete is idempotent for absent key", func(t *testing.T) { + require.NoError(t, v.Delete(t.Context(), "/tenant/ghost")) + }) + + t.Run("stored bytes are isolated from caller mutation", func(t *testing.T) { + key := "/tenant/dave" + in := []byte("secret") + require.NoError(t, v.Write(t.Context(), key, in)) + in[0] = 'X' // mutate caller's slice after Write + + got, err := v.Read(t.Context(), key) + require.NoError(t, err) + require.Equal(t, []byte("secret"), got) + + got[0] = 'Y' // mutate returned slice + again, err := v.Read(t.Context(), key) + require.NoError(t, err) + require.Equal(t, []byte("secret"), again) + }) + }) + } +}