diff --git a/Dockerfile.server b/Dockerfile similarity index 60% rename from Dockerfile.server rename to Dockerfile index 018783f..3f6ee5b 100644 --- a/Dockerfile.server +++ b/Dockerfile @@ -18,19 +18,13 @@ RUN go mod download RUN go build -o chaparral ./cmd/chaparral RUN go build -o chaptoken ./cmd/chaptoken -FROM cgr.dev/chainguard/glibc-dynamic:latest-dev -# FROM cgr.dev/chainguard/glibc-dynamic:latest -USER root -WORKDIR / -COPY config.yaml config.yaml +FROM cgr.dev/chainguard/glibc-dynamic:latest +# FROM cgr.dev/chainguard/glibc-dynamic:latest-dev +COPY --chown=nonroot:nonroot config.yaml /data/config.yaml COPY --from=builder /work/chaparral chaparral COPY --from=builder /work/chaptoken chaptoken -# persistent data -RUN mkdir /data +EXPOSE 8080 -ENV CHAPARRAL_BACKEND="file:///data" -ENV CHAPARRAL_DB="/data/chaparral.sqlite3" -ENV CHAPARRAL_AUTH_PEM="/data/chaparral.pem" -ENV CHAPARRAL_LISTEN=":8080" -CMD ["/chaparral","-c","/config.yaml"] +WORKDIR /data +CMD ["/chaparral","-c","config.yaml"] diff --git a/README.md b/README.md index b266a40..d9672f5 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ On first run, a new default OCFL storage root is initialized if one doesn't exist. In addition, the server will create a new sqlite3 database for internal state and an RSA key for signing auth tokens. +See [`config.yaml`](config.yaml) for configuration options. + ## API Chaparral's server API is defined using protocol buffers/gRPC and implemented diff --git a/client_test.go b/client_test.go index 86ce202..be172d4 100644 --- a/client_test.go +++ b/client_test.go @@ -11,12 +11,11 @@ import ( "github.com/carlmjohnson/be" chap "github.com/srerickson/chaparral" "github.com/srerickson/chaparral/internal/testutil" - "github.com/srerickson/chaparral/server/store" "github.com/srerickson/ocfl-go" ) func TestClientNewUploader(t *testing.T) { - testFn := func(t *testing.T, htc *http.Client, url string, store *store.StorageRoot) { + testFn := func(t *testing.T, htc *http.Client, url string) { ctx := context.Background() cli := chap.NewClient(htc, url) up, err := cli.NewUploader(ctx, []string{"sha256"}, "test") @@ -35,7 +34,7 @@ func TestClientNewUploader(t *testing.T) { } func TestClientCommit(t *testing.T) { - testFn := func(t *testing.T, htc *http.Client, url string, store *store.StorageRoot) { + testFn := func(t *testing.T, htc *http.Client, url string) { ctx := context.Background() cli := chap.NewClient(htc, url) fixture := filepath.Join("testdata", "spec-ex-full") @@ -52,7 +51,7 @@ func TestClientCommit(t *testing.T) { be.NilErr(t, err) commit := &chap.Commit{ To: chap.ObjectRef{ - StorageRootID: store.ID(), + StorageRootID: testutil.TestStoreID, ID: obj1ID, }, State: state, @@ -66,7 +65,7 @@ func TestClientCommit(t *testing.T) { ContentSources: []any{up.UploaderRef}, } be.NilErr(t, cli.Commit(ctx, commit)) - ver, err := cli.GetObjectVersion(ctx, store.ID(), obj1ID, 0) + ver, err := cli.GetObjectVersion(ctx, testutil.TestStoreID, obj1ID, 0) be.NilErr(t, err) be.Equal(t, commit.To.StorageRootID, ver.StorageRootID) be.Equal(t, commit.To.ID, ver.ID) @@ -79,7 +78,7 @@ func TestClientCommit(t *testing.T) { be.DeepEqual(t, commit.User, *ver.User) } for digest := range ver.State { - f, err := cli.GetContent(ctx, store.ID(), obj1ID, digest) + f, err := cli.GetContent(ctx, testutil.TestStoreID, obj1ID, digest) be.NilErr(t, err) _, err = io.Copy(io.Discard, f) be.NilErr(t, err) @@ -97,7 +96,7 @@ func TestClientCommit(t *testing.T) { be.NilErr(t, err) commit := &chap.Commit{ To: chap.ObjectRef{ - StorageRootID: store.ID(), + StorageRootID: testutil.TestStoreID, ID: obj1ID, }, State: stage, @@ -111,7 +110,7 @@ func TestClientCommit(t *testing.T) { ContentSources: []any{up.UploaderRef}, } be.NilErr(t, cli.Commit(ctx, commit)) - ver, err := cli.GetObjectVersion(ctx, store.ID(), obj1ID, 0) + ver, err := cli.GetObjectVersion(ctx, testutil.TestStoreID, obj1ID, 0) be.NilErr(t, err) be.Equal(t, commit.To.StorageRootID, ver.StorageRootID) be.Equal(t, commit.To.ID, ver.ID) @@ -124,7 +123,7 @@ func TestClientCommit(t *testing.T) { be.DeepEqual(t, commit.User, *ver.User) } for digest := range ver.State { - f, err := cli.GetContent(ctx, store.ID(), obj1ID, digest) + f, err := cli.GetContent(ctx, testutil.TestStoreID, obj1ID, digest) be.NilErr(t, err) _, err = io.Copy(io.Discard, f) be.NilErr(t, err) @@ -135,11 +134,11 @@ func TestClientCommit(t *testing.T) { t.Run("fork object", func(t *testing.T) { // created obj2 as fork of obj1's last version // expected - obj1, err := cli.GetObjectVersion(ctx, store.ID(), obj1ID, 0) + obj1, err := cli.GetObjectVersion(ctx, testutil.TestStoreID, obj1ID, 0) be.NilErr(t, err) commit := &chap.Commit{ To: chap.ObjectRef{ - StorageRootID: store.ID(), + StorageRootID: testutil.TestStoreID, ID: obj2ID, }, Version: 1, @@ -151,12 +150,12 @@ func TestClientCommit(t *testing.T) { }, Message: "test fork", ContentSources: []any{ - chap.ObjectRef{StorageRootID: store.ID(), ID: obj1ID}, + chap.ObjectRef{StorageRootID: testutil.TestStoreID, ID: obj1ID}, }, } be.NilErr(t, cli.Commit(ctx, commit)) // result - obj2, err := cli.GetObjectVersion(ctx, store.ID(), obj2ID, 0) + obj2, err := cli.GetObjectVersion(ctx, testutil.TestStoreID, obj2ID, 0) be.NilErr(t, err) be.Equal(t, commit.To.StorageRootID, obj2.StorageRootID) be.Equal(t, commit.To.ID, obj2.ID) @@ -168,7 +167,7 @@ func TestClientCommit(t *testing.T) { be.DeepEqual(t, commit.User, *obj2.User) } for digest := range obj2.State { - f, err := cli.GetContent(ctx, store.ID(), obj2ID, digest) + f, err := cli.GetContent(ctx, testutil.TestStoreID, obj2ID, digest) be.NilErr(t, err) _, err = io.Copy(io.Discard, f) be.NilErr(t, err) diff --git a/cmd/chaparral/chaparral.go b/cmd/chaparral/chaparral.go index 1f58085..525b249 100644 --- a/cmd/chaparral/chaparral.go +++ b/cmd/chaparral/chaparral.go @@ -2,71 +2,23 @@ package main import ( "context" - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "encoding/pem" - "errors" "flag" "fmt" - "github.com/srerickson/chaparral" - "github.com/srerickson/chaparral/server" - "github.com/srerickson/chaparral/server/backend" - "github.com/srerickson/chaparral/server/chapdb" - "github.com/srerickson/chaparral/server/store" - "github.com/srerickson/chaparral/server/uploader" - "github.com/srerickson/ocfl-go" - "log/slog" - "net/http" - "net/url" "os" - "os/signal" - "strings" - "syscall" - "time" - "github.com/go-chi/httplog/v2" + "github.com/srerickson/chaparral/cmd/chaparral/run" + "github.com/kkyr/fig" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" ) var configFile = flag.String("c", "", "config file") -type config struct { - Backend string `fig:"backend" default:"file://."` - Roots []root `fig:"roots"` - Uploads string `fig:"uploads"` - Listen string `fig:"listen"` - StateDB string `fig:"db" default:"chaparral.sqlite3"` - AuthPEM string `fig:"auth_pem" default:"chaparral.pem"` - TLSCert string `fig:"tls_cert"` - TLSKey string `fig:"tls_key"` - Debug bool `fig:"debug"` -} - -type root struct { - ID string `fig:"id"` - Path string `fig:"path" validate:"required"` - Init *struct { - Layout string `fig:"layout" default:"0002-flat-direct-storage-layout"` - Description string `fig:"description"` - } `fig:"init"` -} - -var loggerOptions = httplog.Options{ - JSON: true, - Concise: true, - RequestHeaders: true, - MessageFieldName: "message", -} - func main() { + ctx := context.Background() flag.Parse() - var conf config - + var conf run.Config figOpts := []fig.Option{ + fig.UseStrict(), fig.UseEnv("CHAPARRAL"), fig.File(*configFile), } @@ -74,265 +26,12 @@ func main() { // configure through environment variable only figOpts = append(figOpts, fig.IgnoreFile()) } - if err := fig.Load(&conf, figOpts...); err != nil { fmt.Fprintf(os.Stderr, "config error: %v\n", err) os.Exit(1) } - if conf.Debug { - loggerOptions.LogLevel = slog.LevelDebug - } - logger := httplog.NewLogger("chaparral", loggerOptions) - // sqlite3 for server state - db, err := chapdb.Open("sqlite3", conf.StateDB, true) - if err != nil { - logger.Error(err.Error()) + if err := run.Run(ctx, &conf); err != nil { + fmt.Fprintf(os.Stderr, "server error: %v\n", err) os.Exit(1) } - defer db.Close() - chapDB := (*chapdb.SQLiteDB)(db) - - backend, err := newBackend(conf.Backend) - if err != nil { - logger.Error(fmt.Sprintf("initializing backend: %v", err)) - os.Exit(1) - } - fsys, err := backend.NewFS() - if err != nil { - logger.Error(fmt.Sprintf("backend configuration has errors: %v", err)) - return - } - if ok, err := backend.IsAccessible(); !ok { - logger.Error(fmt.Sprintf("backend not accessible: %v", err), "storage", conf.Backend) - } - var rootPaths []string - var roots []*store.StorageRoot - for _, rootConfig := range conf.Roots { - var init *store.StorageRootInitializer - if rootConfig.Init != nil { - init = &store.StorageRootInitializer{ - Description: rootConfig.Init.Description, - Layout: rootConfig.Init.Layout, - } - } - r := store.NewStorageRoot(rootConfig.ID, fsys, rootConfig.Path, init, chapDB) - roots = append(roots, r) - rootPaths = append(rootPaths, rootConfig.Path) - } - - // authentication config (load RSA key used in JWS signing) - authKey, err := loadRSAKey(conf.AuthPEM) - if err != nil { - logger.Error("error loading auth keyfile", "error", err.Error()) - os.Exit(1) - } - - // upload manager is required for allowing uploads - var mgr *uploader.Manager - if conf.Uploads != "" { - mgr = uploader.NewManager(fsys, conf.Uploads, chapDB) - rootPaths = append(rootPaths, conf.Uploads) - } - - if pathConflict(rootPaths...) { - err := fmt.Errorf("storage root and uploader paths have conflicts: %s", strings.Join(rootPaths, ", ")) - logger.Error(err.Error()) - os.Exit(1) - } - - mux := server.New( - server.WithStorageRoots(roots...), - server.WithUploaderManager(mgr), - server.WithLogger(logger.Logger), - server.WithAuthUserFunc(server.DefaultAuthUserFunc(&authKey.PublicKey)), - server.WithAuthorizer(server.DefaultPermissions()), - server.WithMiddleware( - // log all requests - httplog.RequestLogger(logger), - ), - ) - // healthcheck endpoint - mux.Handle("/alive", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprint(w, "OK") - })) - // grpc reflection endpoint - // reflector := grpcreflect.NewStaticReflector(server.AccessServiceName, server.CommitServiceName) - // mux.Mount(grpcreflect.NewHandlerV1(reflector)) - // mux.Mount(grpcreflect.NewHandlerV1Alpha(reflector)) - - tlsCfg, err := newTLSConfig(conf.TLSCert, conf.TLSKey, "") - if err != nil { - logger.Error(fmt.Sprintf("in TLS config: %v", err)) - os.Exit(1) - } - httpSrv := http.Server{ - Addr: conf.Listen, - TLSConfig: tlsCfg, - } - - logger.Info("starting server", - "version", chaparral.VERSION, - "code_version", chaparral.CODE_VERSION, - "storage", conf.Backend, - "root", conf.Roots, - "uploads", conf.Uploads, - "listen", conf.Listen, - "db", conf.StateDB, - "auth_pem", conf.AuthPEM, - "tls_cert", conf.TLSCert, - "tls_key", conf.TLSKey, - "h2c", tlsCfg == nil, - ) - - // handle shutdown - c := make(chan os.Signal, 2) - signal.Notify(c, os.Interrupt, syscall.SIGTERM) - go func() { - <-c - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - logger.Info("shutting down ...", "deadline", "2 mins") - if err := httpSrv.Shutdown(ctx); err != nil { - if errors.Is(context.DeadlineExceeded, err) { - logger.Error("server didn't shutdown gracefully") - httpSrv.Close() - } - } - if err := db.Close(); err != nil { - logger.Error("shutting down database: " + err.Error()) - } - }() - - var srvErr error - switch { - case httpSrv.TLSConfig == nil: - httpSrv.Handler = h2c.NewHandler(mux, &http2.Server{}) - srvErr = httpSrv.ListenAndServe() - default: - httpSrv.Handler = mux - srvErr = httpSrv.ListenAndServeTLS("", "") - } - if errors.Is(http.ErrServerClosed, srvErr) { - srvErr = nil - } - if srvErr != nil { - logger.Error("server error: " + srvErr.Error()) - } -} - -type Backend interface { - Name() string - IsAccessible() (bool, error) - NewFS() (ocfl.WriteFS, error) -} - -func newBackend(storage string) (Backend, error) { - kind, loc, _ := strings.Cut(storage, "://") - switch kind { - case "file": - return &backend.FileBackend{Path: loc}, nil - case "s3": - bucket, query, _ := strings.Cut(loc, "?") - back := &backend.S3Backend{Bucket: bucket} - if query != "" { - opts, err := url.ParseQuery(query) - if err != nil { - return nil, err - } - back.Options = opts - } - return back, nil - default: - return nil, fmt.Errorf("invalid storage backen: %q", storage) - } -} - -func loadRSAKey(name string) (*rsa.PrivateKey, error) { - bytes, err := os.ReadFile(name) - if err != nil { - if !os.IsNotExist(err) { - return nil, err - } - if err := genRSAKey(name); err != nil { - return nil, err - } - bytes, err = os.ReadFile(name) - if err != nil { - return nil, err - } - } - block, _ := pem.Decode(bytes) - if block == nil { - return nil, errors.New("key is not PEM encoded") - } - anyKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return x509.ParsePKCS1PrivateKey(block.Bytes) - } - switch k := anyKey.(type) { - case *rsa.PrivateKey: - return k, nil - default: - return nil, errors.New("not an rsa key") - } -} - -func genRSAKey(name string) error { - slog.Info("generating new RSA key", "name", name) - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return err - } - keyBytes, err := x509.MarshalPKCS8PrivateKey(key) - if err != nil { - return err - } - f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - return err - } - defer f.Close() - return pem.Encode(f, &pem.Block{ - Type: "PRIVATE KEY", - Bytes: keyBytes, - }) -} - -func newTLSConfig(crt, key, clientCA string) (*tls.Config, error) { - var tlsCfg *tls.Config - if crt != "" || key != "" { - var err error - tlsCfg = &tls.Config{Certificates: make([]tls.Certificate, 1)} - tlsCfg.Certificates[0], err = tls.LoadX509KeyPair(crt, key) - if err != nil { - return nil, err - } - if clientCA != "" { - pem, err := os.ReadFile(clientCA) - if err != nil { - return nil, err - } - clientCAs := x509.NewCertPool() - if !clientCAs.AppendCertsFromPEM(pem) { - return nil, fmt.Errorf("%q not added to certool", clientCA) - } - tlsCfg.ClientCAs = clientCAs - tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert - } - } - return tlsCfg, nil -} - -func pathConflict(paths ...string) bool { - for i, a := range paths { - for _, b := range paths[i+1:] { - if a == b { - return true - } - if a == "." || b == "." || strings.HasPrefix(a, b) || strings.HasPrefix(b, a) { - return true - } - } - } - return false } diff --git a/cmd/chaparral/run/run.go b/cmd/chaparral/run/run.go new file mode 100644 index 0000000..e87f9a7 --- /dev/null +++ b/cmd/chaparral/run/run.go @@ -0,0 +1,325 @@ +package run + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/go-chi/httplog/v2" + "github.com/srerickson/chaparral" + "github.com/srerickson/chaparral/server" + "github.com/srerickson/chaparral/server/backend" + "github.com/srerickson/chaparral/server/chapdb" + "github.com/srerickson/chaparral/server/store" + "github.com/srerickson/chaparral/server/uploader" + "github.com/srerickson/ocfl-go" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +const healthCheck = "/alive" + +type Config struct { + Backend string `fig:"backend" default:"file://."` + Roots []Root `fig:"roots"` + Uploads string `fig:"uploads"` + Listen string `fig:"listen"` + StateDB string `fig:"db" default:"chaparral.sqlite3"` + AuthPEM string `fig:"auth_pem" default:"chaparral.pem"` + TLSCert string `fig:"tls_cert"` + TLSKey string `fig:"tls_key"` + Debug bool `fig:"debug"` + Permissions server.RolePermissions `fig:"permissions"` +} + +type Root struct { + ID string `fig:"id"` + Path string `fig:"path" validate:"required"` + Init *struct { + Layout string `fig:"layout" default:"0002-flat-direct-storage-layout"` + Description string `fig:"description"` + } `fig:"init"` +} + +func Run(ctx context.Context, conf *Config) error { + + var loggerOptions = httplog.Options{ + JSON: true, + Concise: true, + RequestHeaders: true, + MessageFieldName: "message", + } + + if conf.Debug { + loggerOptions.LogLevel = slog.LevelDebug + } + logger := httplog.NewLogger("chaparral", loggerOptions) + + logger.Debug("chaparral version", + "code_version", chaparral.CODE_VERSION, + "version", chaparral.VERSION) + + // sqlite3 for server state + logger.Debug("opening database...", "config", conf.StateDB) + db, err := chapdb.Open("sqlite3", conf.StateDB, true) + if err != nil { + return fmt.Errorf("loading database: %w", err) + } + defer db.Close() + chapDB := (*chapdb.SQLiteDB)(db) + + logger.Debug("initializing backend...", "config", conf.Backend) + backend, err := newBack(conf.Backend) + if err != nil { + return fmt.Errorf("initializing backend: %w", err) + } + + fsys, err := backend.NewFS() + if err != nil { + return fmt.Errorf("backend configuration has errors: %w", err) + } + if ok, err := backend.IsAccessible(); !ok { + return fmt.Errorf("backend is not accessible: %w", err) + } + var rootPaths []string + var roots []*store.StorageRoot + for _, rootConfig := range conf.Roots { + var init *store.StorageRootInitializer + if rootConfig.Init != nil { + init = &store.StorageRootInitializer{ + Description: rootConfig.Init.Description, + Layout: rootConfig.Init.Layout, + } + } + logger.Debug("using storage root", + "id", rootConfig.ID, + "path", rootConfig.Path, + "initialize", init != nil) + r := store.NewStorageRoot(rootConfig.ID, fsys, rootConfig.Path, init, chapDB) + roots = append(roots, r) + rootPaths = append(rootPaths, rootConfig.Path) + } + + // authentication config (load RSA key used in JWS signing) + logger.Debug("using keyfile...", "config", conf.AuthPEM) + authKey, err := loadRSAKey(conf.AuthPEM) + if err != nil { + return fmt.Errorf("loading auth keyfile: %w", err) + } + + // upload manager is required for allowing uploads + var mgr *uploader.Manager + if conf.Uploads != "" { + logger.Debug("uploads are enabled", "config", conf.Uploads) + mgr = uploader.NewManager(fsys, conf.Uploads, chapDB) + rootPaths = append(rootPaths, conf.Uploads) + } + + if pathConflict(rootPaths...) { + return fmt.Errorf("storage root and uploader paths have conflicts: %s", strings.Join(rootPaths, ", ")) + } + + // role definitions + roles := conf.Permissions + if roles.Empty() { + // allow everything: + roles.Default = server.Permissions{"*": []string{"*"}} + } + logger.Debug("default role", "permissions", roles.Default) + + mux := server.New( + server.WithStorageRoots(roots...), + server.WithUploaderManager(mgr), + server.WithLogger(logger.Logger), + server.WithAuthUserFunc(server.JWSAuthFunc(&authKey.PublicKey)), + server.WithAuthorizer(roles), + server.WithMiddleware( + // log all requests + httplog.RequestLogger(logger, []string{healthCheck}), + ), + ) + // healthcheck endpoint + mux.Handle(healthCheck, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "OK") + })) + + // grpc reflection endpoint + // reflector := grpcreflect.NewStaticReflector(server.AccessServiceName, server.CommitServiceName) + // mux.Mount(grpcreflect.NewHandlerV1(reflector)) + // mux.Mount(grpcreflect.NewHandlerV1Alpha(reflector)) + + logger.Debug("TLS config", "cert", conf.TLSCert, "key", conf.TLSKey) + tlsCfg, err := newTLSConfig(conf.TLSCert, conf.TLSKey, "") + if err != nil { + return fmt.Errorf("TLS config errors: %w", err) + } + httpSrv := http.Server{ + Addr: conf.Listen, + TLSConfig: tlsCfg, + } + + logger.Info("starting server", "listen", conf.Listen, "h2c", tlsCfg == nil) + + // handle shutdown + c := make(chan os.Signal, 2) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + go func() { + <-c + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + logger.Info("shutting down ...", "deadline", "2 mins") + if err := httpSrv.Shutdown(ctx); err != nil { + if errors.Is(context.DeadlineExceeded, err) { + logger.Error("server didn't shutdown gracefully") + httpSrv.Close() + } + } + if err := db.Close(); err != nil { + logger.Error("shutting down database: " + err.Error()) + } + }() + + var srvErr error + switch { + case httpSrv.TLSConfig == nil: + httpSrv.Handler = h2c.NewHandler(mux, &http2.Server{}) + srvErr = httpSrv.ListenAndServe() + default: + httpSrv.Handler = mux + srvErr = httpSrv.ListenAndServeTLS("", "") + } + if errors.Is(http.ErrServerClosed, srvErr) { + srvErr = nil + } + return srvErr +} + +type back interface { + Name() string + IsAccessible() (bool, error) + NewFS() (ocfl.WriteFS, error) +} + +func newBack(storage string) (back, error) { + kind, loc, _ := strings.Cut(storage, "://") + switch kind { + case "file": + return &backend.FileBackend{Path: loc}, nil + case "s3": + bucket, query, _ := strings.Cut(loc, "?") + back := &backend.S3Backend{Bucket: bucket} + if query != "" { + opts, err := url.ParseQuery(query) + if err != nil { + return nil, err + } + back.Options = opts + } + return back, nil + default: + return nil, fmt.Errorf("invalid storage backen: %q", storage) + } +} + +func loadRSAKey(name string) (*rsa.PrivateKey, error) { + bytes, err := os.ReadFile(name) + if err != nil { + if !os.IsNotExist(err) { + return nil, err + } + if err := genRSAKey(name); err != nil { + return nil, err + } + bytes, err = os.ReadFile(name) + if err != nil { + return nil, err + } + } + block, _ := pem.Decode(bytes) + if block == nil { + return nil, errors.New("key is not PEM encoded") + } + anyKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return x509.ParsePKCS1PrivateKey(block.Bytes) + } + switch k := anyKey.(type) { + case *rsa.PrivateKey: + return k, nil + default: + return nil, errors.New("not an rsa key") + } +} + +func genRSAKey(name string) error { + slog.Info("generating new RSA key", "name", name) + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + keyBytes, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return err + } + f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + return pem.Encode(f, &pem.Block{ + Type: "PRIVATE KEY", + Bytes: keyBytes, + }) +} + +func newTLSConfig(crt, key, clientCA string) (*tls.Config, error) { + var tlsCfg *tls.Config + if crt != "" || key != "" { + var err error + tlsCfg = &tls.Config{Certificates: make([]tls.Certificate, 1)} + tlsCfg.Certificates[0], err = tls.LoadX509KeyPair(crt, key) + if err != nil { + return nil, err + } + if clientCA != "" { + pem, err := os.ReadFile(clientCA) + if err != nil { + return nil, err + } + clientCAs := x509.NewCertPool() + if !clientCAs.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("%q not added to certool", clientCA) + } + tlsCfg.ClientCAs = clientCAs + tlsCfg.ClientAuth = tls.RequireAndVerifyClientCert + } + } + return tlsCfg, nil +} + +func pathConflict(paths ...string) bool { + for i, a := range paths { + for _, b := range paths[i+1:] { + if a == b { + return true + } + if a == "." || b == "." || strings.HasPrefix(a, b) || strings.HasPrefix(b, a) { + return true + } + } + } + return false +} diff --git a/cmd/chaptoken/main.go b/cmd/chaptoken/main.go index 6e242e6..e82dfdb 100644 --- a/cmd/chaptoken/main.go +++ b/cmd/chaptoken/main.go @@ -13,8 +13,8 @@ import ( "strings" "time" - "github.com/go-jose/go-jose/v3" - "github.com/go-jose/go-jose/v3/jwt" + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" "github.com/srerickson/chaparral/server" ) @@ -65,7 +65,7 @@ func genToken(key *rsa.PrivateKey, id, email, name string, exp int, roles ...str Expiry: jwt.NewNumericDate(time.Now().AddDate(0, 0, exp)), }, } - return jwt.Signed(signer).Claims(token).CompactSerialize() + return jwt.Signed(signer).Claims(token).Serialize() } func readKey(keyfile string) (*rsa.PrivateKey, error) { diff --git a/config.yaml b/config.yaml index 1cbe758..1a918ce 100644 --- a/config.yaml +++ b/config.yaml @@ -1,16 +1,21 @@ -# This is a minimal chaparral configuration. +# This is a basic chaparral configuration. + +# The address:port the server will listen on + +# listen: ":8080" # Storage Backend # # The backend can be set here or using the CHAPARRAL_BACKEND environment -# variable. +# variable. If it's set in both places, the environment variable value +# is used. +# +# Default value (current working directory): +# backend: file://. # -# S3 example +# S3 example: # backend: s3://ocfl-bucket?region=us-west-2 -# FS example -# backend: file:///data - # Upload path # @@ -26,9 +31,36 @@ uploads: "uploads" # doesn't exist, it will be created using values in `init`. Additional storage # roots can be added, but they need to include a unique, non-empty `id` value. roots: -- path: "ocfl-default" # path relative to CHAPARRAL_BACKEND +- id: "main" + path: "ocfl-default" # path relative to backend (CHAPARRAL_BACKEND) init: # if the storage doesn't exist it will be created with - # these optiosn + # these options description: "default storage root" layout: "0003-hash-and-id-n-tuple-storage-layout" + + +# Permissions config +# +# The permissions block defines roles in terms of actions users assigned to +# the role can perform for a set of resources (i.e., OCFL objects). You may +# use whatever naming convention you like for the role names, however actions +# and resources should follow a set form. Allowed actions are `read_object`, +# `commit_object`, `delete_object`, and `*`. The latter matches any action. +# Resources should have the form `root-id::object-id`, where root-id is an +# id set in the Storage Root Config and object-id is the OCFL object id. For +# example, `public::*` matches any object in the `public` storage root; `*::*` +# matches all objects in all storage roots. +# +# The "default" permission is used to set base permissions for all users, +# including un-authenticated requests. +permissions: + default: {} # no access by default + roles: + # members can read any object in the 'main' storage root + chaparral_member: + read_object: ["main::*"] + + # admins can do any action to any resource + chaparral_admin: + "*": ["*::*"] diff --git a/gen/chaparral/v1/access_service.pb.go b/gen/chaparral/v1/access_service.pb.go index aa9da7d..9b6cbbb 100644 --- a/gen/chaparral/v1/access_service.pb.go +++ b/gen/chaparral/v1/access_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.32.0 +// protoc-gen-go v1.33.0 // protoc (unknown) // source: chaparral/v1/access_service.proto diff --git a/gen/chaparral/v1/commit_service.pb.go b/gen/chaparral/v1/commit_service.pb.go index 2f5fbaf..9b1ac1b 100644 --- a/gen/chaparral/v1/commit_service.pb.go +++ b/gen/chaparral/v1/commit_service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.32.0 +// protoc-gen-go v1.33.0 // protoc (unknown) // source: chaparral/v1/commit_service.proto diff --git a/gen/chaparral/v1/core.pb.go b/gen/chaparral/v1/core.pb.go index e510a2e..60b0cee 100644 --- a/gen/chaparral/v1/core.pb.go +++ b/gen/chaparral/v1/core.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.32.0 +// protoc-gen-go v1.33.0 // protoc (unknown) // source: chaparral/v1/core.proto diff --git a/go.mod b/go.mod index e704627..cadaa0c 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/carlmjohnson/be v0.23.2 github.com/go-chi/chi/v5 v5.0.10 github.com/go-chi/httplog/v2 v2.0.7 - github.com/go-jose/go-jose/v3 v3.0.1 + github.com/go-jose/go-jose/v4 v4.0.0 github.com/google/uuid v1.4.0 github.com/kkyr/fig v0.4.0 github.com/mattn/go-sqlite3 v1.14.19 @@ -16,7 +16,7 @@ require ( github.com/srerickson/ocfl-go v0.0.23 gocloud.dev v0.34.0 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa - golang.org/x/net v0.19.0 + golang.org/x/net v0.21.0 google.golang.org/protobuf v1.31.0 ) @@ -51,9 +51,9 @@ require ( github.com/sethvargo/go-retry v0.2.4 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.18.0 // indirect + golang.org/x/crypto v0.21.0 // indirect golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/sys v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect google.golang.org/api v0.151.0 // indirect diff --git a/go.sum b/go.sum index 17b6700..520487a 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI= github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= -github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= -github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-jose/go-jose/v4 v4.0.0 h1:gHOVQyfrqsagdy/Yj9PTz5HMYzr3UpYh1CcFpktmRoY= +github.com/go-jose/go-jose/v4 v4.0.0/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -231,7 +231,6 @@ github.com/srerickson/ocfl-go v0.0.23/go.mod h1:iwrEtWmNIcELJNVPy8U1Pc/ci/HtY7qt github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= @@ -261,11 +260,10 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 gocloud.dev v0.34.0 h1:LzlQY+4l2cMtuNfwT2ht4+fiXwWf/NmPTnXUlLmGif4= gocloud.dev v0.34.0/go.mod h1:psKOachbnvY3DAOPbsFVmLIErwsbWPUG2H5i65D38vE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= @@ -285,8 +283,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= @@ -305,8 +303,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/hack/token.sh b/hack/token.sh index cda1cc4..cfb1e64 100644 --- a/hack/token.sh +++ b/hack/token.sh @@ -1,8 +1,8 @@ PEM_FILE='hack/data/chaparral.pem' # will be created -USER_ID="0" +USER_ID="1" USER_EMAIL="nobody@nothing.never" USER_NAME="test user" -USER_ROLES="chaparral:admin" +USER_ROLES="chaparral_admin" # generate signed client bearer token echo "client bearer token:" diff --git a/internal/testutil/auth_helpers.go b/internal/testutil/auth_helpers.go index 0f2df89..a3dfef5 100644 --- a/internal/testutil/auth_helpers.go +++ b/internal/testutil/auth_helpers.go @@ -8,41 +8,68 @@ import ( "net/http" "time" - "github.com/go-jose/go-jose/v3" - "github.com/go-jose/go-jose/v3/jwt" + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" "github.com/srerickson/chaparral/server" ) +const ( + issuer = "chaparral-test" + roleMember = issuer + "_member" + roleManager = issuer + "_manager" + roleAdmin = issuer + "_admin" +) + var ( // key used for JWS signing/validation in tests key *rsa.PrivateKey // canned users for testing + AnonUser = server.AuthUser{} MemberUser = server.AuthUser{ ID: "test-member", Email: "test-member@testing.com", Name: "Test Member", - Roles: []string{server.MemberRole}} + Roles: []string{roleMember}} ManagerUser = server.AuthUser{ ID: "test-manager", Email: "test-manager@testing.com", Name: "Test Manager", - Roles: []string{server.ManagerRole}} + Roles: []string{roleManager}} AdminUser = server.AuthUser{ ID: "test-admin", Email: "test-admin@testing.com", Name: "Test Admin", - Roles: []string{server.AdminRole}} + Roles: []string{roleAdmin}} // canned permissions used in testing - AllowAll = server.Permissions{ - // anyone can do anythong - server.DefaultRole: []server.RolePermission{ - {Actions: []string{"*"}, StorageRootID: "*"}, + AuthorizeAll = server.RolePermissions{Default: server.Permissions{"*": []string{"*::*"}}} + AuthorizeNone = server.RolePermissions{} + AuthorizeDefaults = DefaultRoles("test") +) + +// DefaultRoles is a set of role permissions used in testing +func DefaultRoles(defaultRoot string) server.RolePermissions { + return server.RolePermissions{ + // No access for un-authenticated users + Default: server.Permissions{}, + Roles: map[string]server.Permissions{ + // members can read objects in the default storage root + roleMember: { + server.ActionReadObject: []string{server.AuthResource(defaultRoot, "*")}, + }, + // managers can read, commit, and delete objects in the default storage + // root + roleManager: { + server.ActionReadObject: []string{server.AuthResource(defaultRoot, "*")}, + server.ActionCommitObject: []string{server.AuthResource(defaultRoot, "*")}, + server.ActionDeleteObject: []string{server.AuthResource(defaultRoot, "*")}, + }, + // admins can do anything to objects in any storage root + roleAdmin: {"*": []string{"*::*"}}, }, } - AllowNone = server.Permissions(nil) -) +} func testKey() *rsa.PrivateKey { if key != nil { @@ -56,45 +83,50 @@ func testKey() *rsa.PrivateKey { return key } -// AuthorizeClient modifies the client to include a bearer token +func AuthUserFunc() server.AuthUserFunc { return server.JWSAuthFunc(&testKey().PublicKey) } + +// SetUserToken modifies the client to include a bearer token // for the given user. The token is signed with testKey. -func authorizeClient(cli *http.Client, user server.AuthUser) { +func SetUserToken(cli *http.Client, user server.AuthUser) { if cli.Transport == nil { cli.Transport = http.DefaultTransport } + if existing, ok := cli.Transport.(*bearerTokenTransport); ok { + existing.Token = authUserToken(user) + return + } cli.Transport = &bearerTokenTransport{ - Token: AuthUserToken(user), + Token: authUserToken(user), Base: cli.Transport, } } -// AuthUserToken generates a token for the given user signed with the test kßey. -func AuthUserToken(user server.AuthUser) string { +// authUserToken generates a token for the given user signed with the test kßey. +func authUserToken(user server.AuthUser) string { key := testKey() signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT")) if err != nil { panic(fmt.Errorf("user token signing: %v", err)) } + now := time.Now() token := server.AuthToken{ User: user, Claims: jwt.Claims{ - Issuer: "chaparral-test", + Issuer: issuer, Subject: user.ID, - Audience: jwt.Audience{"chaparral-test"}, - IssuedAt: jwt.NewNumericDate(time.Now()), - NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)), - Expiry: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), + Audience: jwt.Audience{issuer}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Hour)), + Expiry: jwt.NewNumericDate(now.Add(1 * time.Hour)), }, } - encToken, err := jwt.Signed(signer).Claims(token).CompactSerialize() + encToken, err := jwt.Signed(signer).Claims(token).Serialize() if err != nil { panic(fmt.Errorf("user token signing: %v", err)) } return encToken } -// Permissions used for testing - type bearerTokenTransport struct { Token string Base http.RoundTripper diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 8aaf544..8961bfb 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -25,6 +25,8 @@ import ( "github.com/srerickson/ocfl-go/extension" ) +const TestStoreID = "test" + var ( s3Env = "CHAPARRAL_TEST_S3" storeConf = store.StorageRootInitializer{ @@ -33,60 +35,63 @@ var ( } ) -type ServiceTestFunc func(t *testing.T, cli *http.Client, url string, store *store.StorageRoot) +type ServiceTestFunc func(t *testing.T, cli *http.Client, url string) func RunServiceTest(t *testing.T, tests ...ServiceTestFunc) { logger := slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{})) - authFn := server.DefaultAuthUserFunc(&testKey().PublicKey) opts := []server.Option{ server.WithLogger(logger), - server.WithAuthUserFunc(authFn), - server.WithAuthorizer(server.DefaultPermissions()), + server.WithAuthUserFunc(AuthUserFunc()), + server.WithAuthorizer(DefaultRoles("test")), } t.Run("local-root", func(t *testing.T) { - db, err := chapdb.Open("sqlite3", ":memory:", true) - if err != nil { - t.Fatal(err) - } - defer db.Close() + db := TestDB(t) store := NewStoreTempDir(t) - mgr := uploader.NewManager(store.FS(), "uploads", (*chapdb.SQLiteDB)(db)) + mgr := uploader.NewManager(store.FS(), "uploads", db) mux := server.New(append(opts, server.WithStorageRoots(store), server.WithUploaderManager(mgr))...) testSrv := httptest.NewTLSServer(mux) testCli := testSrv.Client() - authorizeClient(testSrv.Client(), AdminUser) + SetUserToken(testSrv.Client(), ManagerUser) defer testSrv.Close() for _, ts := range tests { - ts(t, testCli, testSrv.URL, store) + ts(t, testCli, testSrv.URL) } }) - if WithS3() { - t.Run("s3-root", func(t *testing.T) { - db, err := chapdb.Open("sqlite3", ":memory:", true) - if err != nil { - t.Fatal(err) - } - defer db.Close() - root := NewStoreS3(t) - mgr := uploader.NewManager(root.FS(), "uploads", (*chapdb.SQLiteDB)(db)) - mux := server.New(append(opts, - server.WithStorageRoots(root), - server.WithUploaderManager(mgr))...) - testSrv := httptest.NewTLSServer(mux) - testCli := testSrv.Client() - authorizeClient(testSrv.Client(), AdminUser) - defer testSrv.Close() - for _, ts := range tests { - ts(t, testCli, testSrv.URL, root) - } - }) + if !S3Enabled() { + return + } + t.Run("s3-root", func(t *testing.T) { + db := TestDB(t) + root := NewStoreS3(t) + mgr := uploader.NewManager(root.FS(), "uploads", db) + mux := server.New(append(opts, + server.WithStorageRoots(root), + server.WithUploaderManager(mgr))...) + testSrv := httptest.NewTLSServer(mux) + testCli := testSrv.Client() + SetUserToken(testSrv.Client(), ManagerUser) + defer testSrv.Close() + for _, ts := range tests { + ts(t, testCli, testSrv.URL) + } + }) +} + +func TestDB(t *testing.T) *chapdb.SQLiteDB { + db, err := chapdb.Open("sqlite3", ":memory:", true) + if err != nil { + t.Fatal(err) } + t.Cleanup(func() { + db.Close() + }) + return (*chapdb.SQLiteDB)(db) } // Test using S3 backends -func WithS3() bool { return os.Getenv(s3Env) != "" } +func S3Enabled() bool { return os.Getenv(s3Env) != "" } func S3Session() (*s3.S3, error) { sess, err := session.NewSession(&aws.Config{ @@ -108,12 +113,8 @@ func NewStoreTestdata(t *testing.T, testdataPath string) *store.StorageRoot { if err != nil { t.Fatal(err) } - db, err := chapdb.Open("sqlite3", ":memory:", true) - if err != nil { - t.Fatal(err) - } dir := path.Join("storage-roots", "root-01") - root := store.NewStorageRoot("test", fsys, dir, nil, (*chapdb.SQLiteDB)(db)) + root := store.NewStorageRoot(TestStoreID, fsys, dir, nil, TestDB(t)) if err := root.Ready(context.Background()); err != nil { t.Fatal(err) } @@ -127,11 +128,7 @@ func NewStoreTempDir(t *testing.T) *store.StorageRoot { if err != nil { t.Fatal(err) } - db, err := chapdb.Open("sqlite3", ":memory:", true) - if err != nil { - t.Fatal(err) - } - root := store.NewStorageRoot("test", fsys, "ocfl", &storeConf, (*chapdb.SQLiteDB)(db)) + root := store.NewStorageRoot(TestStoreID, fsys, "ocfl", &storeConf, TestDB(t)) if err := root.Ready(context.Background()); err != nil { t.Fatal(err) } @@ -146,11 +143,7 @@ func NewStoreS3(t *testing.T) *store.StorageRoot { if err != nil { t.Fatal(err) } - db, err := chapdb.Open("sqlite3", ":memory:", true) - if err != nil { - t.Fatal(err) - } - root := store.NewStorageRoot("test", fsys, "ocfl", &storeConf, (*chapdb.SQLiteDB)(db)) + root := store.NewStorageRoot(TestStoreID, fsys, "ocfl", &storeConf, TestDB(t)) if err := root.Ready(context.Background()); err != nil { t.Fatal(err) } @@ -244,3 +237,10 @@ func randName(prefix string) string { } return prefix + hex.EncodeToString(byt) } + +func Must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} diff --git a/server/access_service.go b/server/access_service.go index eabb907..e0083d0 100644 --- a/server/access_service.go +++ b/server/access_service.go @@ -42,8 +42,8 @@ func (s *AccessService) GetObjectVersion(ctx context.Context, req *connect.Reque chap.QueryObjectID, req.Msg.ObjectId, "version", req.Msg.Version, ) - user := AuthUserFromCtx(ctx) - if s.auth != nil && !s.auth.RootActionAllowed(ctx, &user, ReadAction, req.Msg.StorageRootId) { + authResource := AuthResource(req.Msg.StorageRootId, req.Msg.ObjectId) + if s.auth != nil && !s.auth.Allowed(ctx, ActionReadObject, authResource) { err := errors.New("you don't have permission to read from the storage root") return nil, connect.NewError(connect.CodePermissionDenied, err) } @@ -89,8 +89,8 @@ func (s *AccessService) GetObjectManifest(ctx context.Context, req *connect.Requ chap.QueryStorageRoot, req.Msg.StorageRootId, chap.QueryObjectID, req.Msg.ObjectId, ) - user := AuthUserFromCtx(ctx) - if s.auth != nil && !s.auth.RootActionAllowed(ctx, &user, ReadAction, req.Msg.StorageRootId) { + authResource := AuthResource(req.Msg.StorageRootId, req.Msg.ObjectId) + if s.auth != nil && !s.auth.Allowed(ctx, ActionReadObject, authResource) { err := errors.New("you don't have permission to read from the storage root") return nil, connect.NewError(connect.CodePermissionDenied, err) } @@ -134,8 +134,8 @@ func (srv *AccessService) DownloadHandler(w http.ResponseWriter, r *http.Request objectID = r.URL.Query().Get(chap.QueryObjectID) digest = r.URL.Query().Get(chap.QueryDigest) contentPath = r.URL.Query().Get(chap.QueryContentPath) - user = AuthUserFromCtx(ctx) - logger = LoggerFromCtx(ctx).With( + // user = AuthUserFromCtx(ctx) + logger = LoggerFromCtx(ctx).With( chap.QueryStorageRoot, storeID, chap.QueryObjectID, objectID, chap.QueryDigest, digest, @@ -151,6 +151,11 @@ func (srv *AccessService) DownloadHandler(w http.ResponseWriter, r *http.Request fmt.Fprint(w, err.Error()) } }() + if objectID == "" { + w.WriteHeader(http.StatusBadRequest) + err = errors.New("malformed or missing object id") + return + } if contentPath == "" && digest == "" { err = errors.New("must provide 'content_path' or 'digest' query parameters") w.WriteHeader(http.StatusBadRequest) @@ -161,17 +166,12 @@ func (srv *AccessService) DownloadHandler(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusNotFound) return } - if srv.auth != nil && !srv.auth.RootActionAllowed(ctx, &user, ReadAction, storeID) { + authResource := AuthResource(storeID, objectID) + if srv.auth != nil && !srv.auth.Allowed(ctx, ActionReadObject, authResource) { w.WriteHeader(http.StatusUnauthorized) err = errors.New("you don't have permission to download from the storage root") return } - if objectID == "" { - w.WriteHeader(http.StatusBadRequest) - err = errors.New("malformed or missing object id") - return - } - // make sure storage root's base is initialized if err = root.Ready(ctx); err != nil { w.WriteHeader(http.StatusInternalServerError) diff --git a/server/access_service_test.go b/server/access_service_test.go index 3776179..9e0ac77 100644 --- a/server/access_service_test.go +++ b/server/access_service_test.go @@ -2,13 +2,14 @@ package server_test import ( "context" + "errors" "io" "net/http" "net/http/httptest" "net/url" - "path" "path/filepath" "testing" + "time" "github.com/bufbuild/connect-go" "github.com/carlmjohnson/be" @@ -17,8 +18,6 @@ import ( "github.com/srerickson/chaparral/gen/chaparral/v1/chaparralv1connect" "github.com/srerickson/chaparral/internal/testutil" "github.com/srerickson/chaparral/server" - "github.com/srerickson/ocfl-go" - "github.com/srerickson/ocfl-go/ocflv1" ) // digest from testdata manifest @@ -32,26 +31,19 @@ var _ chaparralv1connect.AccessServiceHandler = (*server.AccessService)(nil) func TestAccessServiceHandler(t *testing.T) { ctx := context.Background() testdataDir := filepath.Join("..", "testdata") - storePath := path.Join("storage-roots", "root-01") objectID := "ark:123/abc" storeID := "test" - storeA := testutil.NewStoreTestdata(t, testdataDir) - mux := server.New(server.WithStorageRoots(storeA)) + store := testutil.NewStoreTestdata(t, testdataDir) + mux := server.New(server.WithStorageRoots(store), + server.WithAuthorizer(testutil.AuthorizeDefaults), + server.WithAuthUserFunc(testutil.AuthUserFunc())) srv := httptest.NewTLSServer(mux) defer srv.Close() httpClient := srv.Client() // load fixture for comparison - storeB, err := ocflv1.GetStore(ctx, ocfl.DirFS(testdataDir), storePath) - if err != nil { - t.Fatal("in test setup:", err) - } - obj, err := storeB.GetObject(ctx, objectID) - if err != nil { - t.Fatal("in test setup:", err) - } - expectState := obj.Inventory.Version(0).State t.Run("get object version", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.ManagerUser) chap := chaparralv1connect.NewAccessServiceClient(httpClient, srv.URL) req := connect.NewRequest(&chaparralv1.GetObjectVersionRequest{ StorageRootId: storeID, @@ -59,16 +51,55 @@ func TestAccessServiceHandler(t *testing.T) { }) resp, err := chap.GetObjectVersion(ctx, req) be.NilErr(t, err) - got := map[string]string{} - for d, info := range resp.Msg.State { - for _, p := range info.Paths { - got[p] = d - } - } - be.DeepEqual(t, expectState.PathMap(), got) + be.Equal(t, storeID, resp.Msg.StorageRootId) + be.Equal(t, objectID, resp.Msg.ObjectId) + be.Equal(t, "a_file.txt", resp.Msg.State[testDigest].Paths[0]) + be.Equal(t, "A Person", resp.Msg.User.Name) + be.Equal(t, "mailto:a_person@example.org", resp.Msg.User.Address) + be.Equal(t, "An version with one file", resp.Msg.Message) + be.Equal(t, "sha512", resp.Msg.DigestAlgorithm) + be.Equal(t, int32(1), resp.Msg.Head) + be.Equal(t, int32(1), resp.Msg.Version) + be.Equal(t, testutil.Must(time.Parse(time.RFC3339, "2019-01-01T02:03:04Z")), resp.Msg.Created.AsTime()) + be.Equal(t, "1.0", resp.Msg.Spec) + + t.Run("unauthorized", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.AnonUser) + _, err := chap.GetObjectVersion(ctx, req) + be.True(t, err != nil) + var conErr *connect.Error + be.True(t, errors.As(err, &conErr)) + be.Equal(t, connect.CodePermissionDenied, conErr.Code()) + }) + }) + + t.Run("get object manifest", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.ManagerUser) + chap := chaparralv1connect.NewAccessServiceClient(httpClient, srv.URL) + req := connect.NewRequest(&chaparralv1.GetObjectManifestRequest{ + StorageRootId: storeID, + ObjectId: objectID, + }) + resp, err := chap.GetObjectManifest(ctx, req) + be.NilErr(t, err) + be.Equal(t, storeID, resp.Msg.StorageRootId) + be.Equal(t, objectID, resp.Msg.ObjectId) + be.Equal(t, "v1/content/a_file.txt", resp.Msg.Manifest[testDigest].Paths[0]) + be.Equal(t, "sha512", resp.Msg.DigestAlgorithm) + be.Equal(t, "1.0", resp.Msg.Spec) + + t.Run("unauthorized", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.AnonUser) + _, err := chap.GetObjectManifest(ctx, req) + be.True(t, err != nil) + var conErr *connect.Error + be.True(t, errors.As(err, &conErr)) + be.Equal(t, connect.CodePermissionDenied, conErr.Code()) + }) }) t.Run("download by content path", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.ManagerUser) vals := url.Values{ chap.QueryContentPath: {"inventory.json"}, chap.QueryObjectID: {objectID}, @@ -87,6 +118,7 @@ func TestAccessServiceHandler(t *testing.T) { }) t.Run("download by digest", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.ManagerUser) vals := url.Values{ chap.QueryDigest: {testDigest}, chap.QueryObjectID: {objectID}, @@ -95,15 +127,24 @@ func TestAccessServiceHandler(t *testing.T) { u := srv.URL + chap.RouteDownload + "?" + vals.Encode() resp, err := httpClient.Get(u) be.NilErr(t, err) - defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() if resp.StatusCode != 200 { - b, _ := io.ReadAll(resp.Body) t.Fatalf("status=%d, resp=%s", resp.StatusCode, string(b)) } be.Equal(t, contentLength, resp.ContentLength) + + t.Run("unauthorized", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.AnonUser) + resp, err := httpClient.Get(u) + be.NilErr(t, err) + be.Equal(t, http.StatusUnauthorized, resp.StatusCode) + resp.Body.Close() + }) }) t.Run("head by digest", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.ManagerUser) vals := url.Values{ chap.QueryDigest: {testDigest}, chap.QueryObjectID: {objectID}, @@ -112,12 +153,13 @@ func TestAccessServiceHandler(t *testing.T) { u := srv.URL + chap.RouteDownload + "?" + vals.Encode() resp, err := httpClient.Head(u) be.NilErr(t, err) - defer resp.Body.Close() + resp.Body.Close() be.Equal(t, 200, resp.StatusCode) be.Equal(t, contentLength, resp.ContentLength) }) t.Run("download dir", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.ManagerUser) vals := url.Values{ chap.QueryContentPath: {"v1"}, chap.QueryObjectID: {objectID}, @@ -126,11 +168,12 @@ func TestAccessServiceHandler(t *testing.T) { u := srv.URL + chap.RouteDownload + "?" + vals.Encode() resp, err := httpClient.Get(u) be.NilErr(t, err) - defer resp.Body.Close() + resp.Body.Close() be.Equal(t, http.StatusBadRequest, resp.StatusCode) }) t.Run("download missing content", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.ManagerUser) vals := url.Values{ chap.QueryContentPath: {"nothing"}, chap.QueryObjectID: {objectID}, @@ -139,11 +182,12 @@ func TestAccessServiceHandler(t *testing.T) { u := srv.URL + chap.RouteDownload + "?" + vals.Encode() resp, err := httpClient.Get(u) be.NilErr(t, err) - defer resp.Body.Close() + resp.Body.Close() be.Equal(t, http.StatusNotFound, resp.StatusCode) }) t.Run("download missing digest", func(t *testing.T) { + testutil.SetUserToken(httpClient, testutil.ManagerUser) vals := url.Values{ chap.QueryDigest: {"nothing"}, chap.QueryObjectID: {objectID}, @@ -152,7 +196,7 @@ func TestAccessServiceHandler(t *testing.T) { u := srv.URL + chap.RouteDownload + "?" + vals.Encode() resp, err := httpClient.Get(u) be.NilErr(t, err) - defer resp.Body.Close() + resp.Body.Close() be.Equal(t, http.StatusNotFound, resp.StatusCode) }) } diff --git a/server/auth.go b/server/auth.go index bdbb1ab..dbe3a32 100644 --- a/server/auth.go +++ b/server/auth.go @@ -4,7 +4,6 @@ package server import ( "context" - "crypto/rsa" "encoding/json" "fmt" "net/http" @@ -12,31 +11,18 @@ import ( "strings" "time" - "github.com/go-jose/go-jose/v3" - "github.com/go-jose/go-jose/v3/jwt" + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" ) const ( // actions - ReadAction string = "read" - CommitAction string = "write" - DeleteAction string = "delete" - AdminAction string = "administer" - - rolePrefix = "chaparral" - - // built-in user roles - - // The DefaultRole can be used to assign permissions to all users, even - // un-authenticated ones. The default role is attached to users implicitly. - // It doesn't need to be included in the user roles. - DefaultRole = rolePrefix + ":default" - MemberRole = rolePrefix + ":member" - ManagerRole = rolePrefix + ":manager" - AdminRole = rolePrefix + ":admin" -) + ActionReadObject = "read_object" + ActionCommitObject = "commit_object" + ActionDeleteObject = "delete_object" -// var pkenv = strings.ToUpper(rolePrefix) + "_JWK" + permSep = "::" +) type userCtxKey struct{} @@ -70,9 +56,9 @@ type AuthToken struct { User AuthUser `json:"chaparral"` } -// DefaultAuthUserFunc returns an Authentication func that looks -// for a signed JWT bearer token. -func DefaultAuthUserFunc(pub *rsa.PublicKey) AuthUserFunc { +// JWSAuthFunc returns an Authentication func that looks +// for a jwt bearer token signed with the public key. +func JWSAuthFunc(pubkey any) AuthUserFunc { auth := func(r *http.Request) (user AuthUser, err error) { authHeader := r.Header.Get("Authorization") _, encToken, _ := strings.Cut(authHeader, " ") @@ -80,12 +66,12 @@ func DefaultAuthUserFunc(pub *rsa.PublicKey) AuthUserFunc { // no header token return } - sig, err := jose.ParseSigned(encToken) + sig, err := jose.ParseSigned(encToken, []jose.SignatureAlgorithm{jose.RS256, jose.RS512}) if err != nil { err = fmt.Errorf("parsing auth token: %w", err) return } - payload, err := sig.Verify(pub) + payload, err := sig.Verify(pubkey) if err != nil { err = fmt.Errorf("auth token signature verification failed: %w", err) return @@ -127,83 +113,68 @@ func AuthUserMiddleware(authFn AuthUserFunc) func(http.Handler) http.Handler { // Authorizer is an interface used by types that can perform authorziation // for requests. -// TODO: simplify this interface by removing user arg from methods; get -// the user from the ctx. type Authorizer interface { - // RootActionAllowed returns true if the user is allowed to perform action + // Allowed returns true if the user is allowed to perform action // on the resource with the given root_id. - RootActionAllowed(ctx context.Context, user *AuthUser, action, root_id string) bool - // ActionAllowed return true if the user has permission to perform action - // on at least one resource. - ActionAllowed(ctx context.Context, user *AuthUser, action string) bool + Allowed(ctx context.Context, action string, resources string) bool } -// Permissions is a map of roles to permissions. It implements the Authorizer +// RolePermissions is a map of role names to Permissions. It implements the Authorizer // interface. -type Permissions map[string][]RolePermission - -// RootActionAllowed returns true if the user has a role with a permission -// allowing the action on the resource with the given group and root ids. -func (p Permissions) RootActionAllowed(_ context.Context, user *AuthUser, action, root string) bool { - roles := []string{DefaultRole} - if user != nil { - roles = append(roles, user.Roles...) - } - return slices.ContainsFunc(roles, func(r string) bool { - return slices.ContainsFunc(p[r], func(rp RolePermission) bool { - return rp.allowRootAction(action, root) - }) - }) +type RolePermissions struct { + // Default permissions that apply to all users and un-authenticated requests + Default Permissions `json:"default"` + Roles map[string]Permissions `json:"roles"` } -func (p Permissions) ActionAllowed(_ context.Context, user *AuthUser, action string) bool { - roles := []string{DefaultRole} - if user != nil { - roles = append(roles, user.Roles...) +func (r RolePermissions) Empty() bool { return len(r.Roles) < 1 && len(r.Default) < 1 } + +// Allowed returns true if the user associated with the context has a role with a permission +// allowing the action on the resource. If resource is '*', Allowed returns true if +// the if the action is allowed for any resource. +func (r RolePermissions) Allowed(ctx context.Context, action string, resource string) bool { + user := AuthUserFromCtx(ctx) + if r.Default.allow(action, resource) { + return true } - return slices.ContainsFunc(roles, func(r string) bool { - return slices.ContainsFunc(p[r], func(rp RolePermission) bool { - return rp.allowAction(action) - }) + return slices.ContainsFunc(user.Roles, func(role string) bool { + perm, ok := r.Roles[role] + if !ok { + return false + } + return perm.allow(action, resource) }) } -type RolePermission struct { - Actions []string `json:"actions"` - StorageRootID string `json:"storage_root_id"` +// Permissions maps actions to resources for which the action is allowed. +type Permissions map[string][]string + +func (p Permissions) allow(action string, resource string) bool { + for _, act := range []string{action, "*"} { + ok := slices.ContainsFunc(p[act], func(okResource string) bool { + return resousrceMatch(resource, okResource) + }) + if ok { + return true + } + } + return false } -func (p RolePermission) allowAction(action string) bool { - return slices.ContainsFunc(p.Actions, func(a string) bool { - return a == "*" || a == action - }) +func AuthResource(root, obj string) string { + return root + permSep + obj } -func (p RolePermission) allowRootAction(action, root string) bool { - if !p.allowAction(action) { +func resousrceMatch(a, b string) bool { + aRoot, aObj, aFound := strings.Cut(a, permSep) + if !aFound || aRoot == "" || aObj == "" { return false } - return (p.StorageRootID == "*" || p.StorageRootID == root) -} - -// DefaultPermissions returns the default server Permissions. -func DefaultPermissions() Permissions { - return Permissions{ - // No access for un-authenticated users - DefaultRole: []RolePermission{}, - // members can read objects in the default storage root - MemberRole: []RolePermission{ - {Actions: []string{ReadAction}}, - }, - // managers can read, commit, and delete objects in the default storage - // root - ManagerRole: []RolePermission{ - // storage root - {Actions: []string{ReadAction, CommitAction, DeleteAction}}, - }, - // admins can do anything to objects in any storage root - AdminRole: []RolePermission{ - {Actions: []string{"*"}, StorageRootID: "*"}, - }, + bRoot, bObj, bFound := strings.Cut(b, permSep) + if !bFound || bRoot == "" || bObj == "" { + return false } + return (aRoot == bRoot || aRoot == "*" || bRoot == "*") && + (aObj == bObj || aObj == "*" || bObj == "*") + } diff --git a/server/auth_test.go b/server/auth_test.go index 3489193..7e3227f 100644 --- a/server/auth_test.go +++ b/server/auth_test.go @@ -9,34 +9,48 @@ import ( "github.com/srerickson/chaparral/server" ) -var _ server.Authorizer = (server.Permissions)(nil) +var _ server.Authorizer = (*server.RolePermissions)(nil) -func TestDefaultPermissions(t *testing.T) { +func TestRolePermissions(t *testing.T) { ctx := context.Background() - perms := server.DefaultPermissions() - - be.False(t, perms.RootActionAllowed(ctx, &server.AuthUser{}, server.ReadAction, "")) - be.False(t, perms.RootActionAllowed(ctx, &server.AuthUser{}, server.CommitAction, "")) - be.False(t, perms.RootActionAllowed(ctx, &server.AuthUser{}, server.DeleteAction, "")) - be.False(t, perms.RootActionAllowed(ctx, &server.AuthUser{}, server.AdminAction, "")) - - be.True(t, perms.RootActionAllowed(ctx, &testutil.MemberUser, server.ReadAction, "")) - be.False(t, perms.RootActionAllowed(ctx, &testutil.MemberUser, server.ReadAction, "anything")) - - be.False(t, perms.RootActionAllowed(ctx, &testutil.MemberUser, server.CommitAction, "")) - be.False(t, perms.RootActionAllowed(ctx, &testutil.MemberUser, server.DeleteAction, "")) - be.False(t, perms.RootActionAllowed(ctx, &testutil.MemberUser, server.AdminAction, "")) - - be.True(t, perms.RootActionAllowed(ctx, &testutil.ManagerUser, server.ReadAction, "")) - be.True(t, perms.RootActionAllowed(ctx, &testutil.ManagerUser, server.CommitAction, "")) - be.True(t, perms.RootActionAllowed(ctx, &testutil.ManagerUser, server.DeleteAction, "")) - - be.False(t, perms.RootActionAllowed(ctx, &testutil.ManagerUser, server.AdminAction, "")) - be.False(t, perms.RootActionAllowed(ctx, &testutil.ManagerUser, server.ReadAction, "anything")) - be.False(t, perms.RootActionAllowed(ctx, &testutil.ManagerUser, server.CommitAction, "anything")) - be.False(t, perms.RootActionAllowed(ctx, &testutil.ManagerUser, server.DeleteAction, "anyting")) - be.False(t, perms.RootActionAllowed(ctx, &testutil.ManagerUser, server.AdminAction, "anything")) - - be.True(t, perms.ActionAllowed(ctx, &testutil.AdminUser, "anyting")) - be.True(t, perms.RootActionAllowed(ctx, &testutil.AdminUser, "anything", "anyting")) + perms := testutil.DefaultRoles("main") + + be.False(t, perms.Allowed(ctx, server.ActionReadObject, "*::*")) + be.False(t, perms.Allowed(ctx, server.ActionCommitObject, "*::*")) + be.False(t, perms.Allowed(ctx, server.ActionDeleteObject, "*::*")) + be.False(t, perms.Allowed(ctx, "*", "*::*")) + + // members can only read from the default storage root + memberCtx := server.CtxWithAuthUser(ctx, testutil.MemberUser) + be.True(t, perms.Allowed(memberCtx, server.ActionReadObject, "main::object")) + be.True(t, perms.Allowed(memberCtx, server.ActionReadObject, "*::*")) + be.False(t, perms.Allowed(memberCtx, server.ActionReadObject, "private::object")) + be.False(t, perms.Allowed(memberCtx, server.ActionCommitObject, "main::object")) + be.False(t, perms.Allowed(memberCtx, server.ActionCommitObject, "*::*")) + be.False(t, perms.Allowed(memberCtx, server.ActionDeleteObject, "main::object")) + be.False(t, perms.Allowed(memberCtx, server.ActionDeleteObject, "*::*")) + be.False(t, perms.Allowed(memberCtx, "*", "*::*")) + + // manager role can do anything to objects in default storage root + managerCtx := server.CtxWithAuthUser(ctx, testutil.ManagerUser) + be.True(t, perms.Allowed(managerCtx, server.ActionReadObject, "main::object")) + be.True(t, perms.Allowed(managerCtx, server.ActionReadObject, "*::*")) + be.True(t, perms.Allowed(managerCtx, server.ActionReadObject, "main::object")) + be.True(t, perms.Allowed(managerCtx, server.ActionCommitObject, "*::*")) + be.True(t, perms.Allowed(managerCtx, server.ActionDeleteObject, "main::object")) + be.True(t, perms.Allowed(managerCtx, server.ActionDeleteObject, "*::*")) + be.True(t, perms.Allowed(managerCtx, server.ActionCommitObject, "main::object")) + + // managers can't do anything to objects outside the default storage root + be.False(t, perms.Allowed(managerCtx, server.ActionReadObject, "private::object")) + be.False(t, perms.Allowed(managerCtx, server.ActionCommitObject, "private::object")) + be.False(t, perms.Allowed(managerCtx, server.ActionDeleteObject, "private::object")) + be.False(t, perms.Allowed(managerCtx, "action", "private::object")) + be.False(t, perms.Allowed(managerCtx, "*", "*::*")) + + // admin role can do anything + adminCtx := server.CtxWithAuthUser(ctx, testutil.AdminUser) + be.True(t, perms.Allowed(adminCtx, "action", "private::object")) + be.True(t, perms.Allowed(adminCtx, "action", "main::object")) + be.True(t, perms.Allowed(adminCtx, "*", "*::*")) } diff --git a/server/chapdb/sqlite.go b/server/chapdb/sqlite.go index d8426c9..2bbb85b 100644 --- a/server/chapdb/sqlite.go +++ b/server/chapdb/sqlite.go @@ -54,6 +54,10 @@ func Open(driver string, file string, migrate bool) (*sql.DB, error) { return db, nil } +func (db *SQLiteDB) Close() error { + return (*sql.DB)(db).Close() +} + func (db *SQLiteDB) sqlDB() *sql.DB { return (*sql.DB)(db) } diff --git a/server/commit_service.go b/server/commit_service.go index 0a9801b..f01edb1 100644 --- a/server/commit_service.go +++ b/server/commit_service.go @@ -299,6 +299,7 @@ func (s *CommitService) ListUploaders(ctx context.Context, req *connect.Request[ err := errors.New("the storage root does not allow uploading") return nil, connect.NewError(connect.CodeInvalidArgument, err) } + // TODO: only list uploaders owned by the user ids, err := s.uploadMgr.UploaderIDs(ctx) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) @@ -352,6 +353,7 @@ func (s *CommitService) DeleteUploader(ctx context.Context, req *connect.Request logger.Error(err.Error()) } }() + // TODO: only allow deleting uploaders created by the user if err := upper.Delete(noCancelCtx); err != nil { return nil, connect.NewError(connect.CodeAborted, err) } @@ -362,7 +364,6 @@ func (s *CommitService) DeleteUploader(ctx context.Context, req *connect.Request // Handler for file uploads. func (s *CommitService) HandleUpload(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - user := AuthUserFromCtx(ctx) logger := LoggerFromCtx(ctx) result := chap.Upload{} var errMsg string @@ -382,7 +383,7 @@ func (s *CommitService) HandleUpload(w http.ResponseWriter, r *http.Request) { }() uploaderID := r.URL.Query().Get(chap.QueryUploaderID) - if s.auth != nil && !s.auth.ActionAllowed(ctx, &user, CommitAction) { + if s.auth != nil && !s.auth.Allowed(ctx, ActionCommitObject, "*::*") { w.WriteHeader(http.StatusUnauthorized) errMsg = "you don't have permission to upload files" return @@ -403,6 +404,7 @@ func (s *CommitService) HandleUpload(w http.ResponseWriter, r *http.Request) { logger.Error(err.Error()) } }() + // TODO: only allow uploading to own uploaders upload, err := upper.Write(ctx, r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) @@ -426,31 +428,32 @@ func (s *CommitService) AuthorizeInterceptor() connect.UnaryInterceptorFunc { // just for server side return next(ctx, req) } - user := AuthUserFromCtx(ctx) var ok bool switch msg := req.Any().(type) { case *chaparralv1.CommitRequest: - ok = s.auth.RootActionAllowed(ctx, &user, CommitAction, msg.StorageRootId) + resource := AuthResource(msg.StorageRootId, msg.ObjectId) + ok = s.auth.Allowed(ctx, ActionCommitObject, resource) if !ok { break } for _, item := range msg.ContentSources { // check permission to read source object (if commit uses one) if obj, isObj := item.Item.(*chaparralv1.CommitRequest_ContentSourceItem_Object); isObj { - ok = s.auth.RootActionAllowed(ctx, &user, ReadAction, obj.Object.StorageRootId) + resource := AuthResource(obj.Object.StorageRootId, obj.Object.ObjectId) + ok = s.auth.Allowed(ctx, ActionReadObject, resource) } - } case *chaparralv1.DeleteObjectRequest: - ok = s.auth.RootActionAllowed(ctx, &user, DeleteAction, msg.StorageRootId) + resource := AuthResource(msg.StorageRootId, msg.ObjectId) + ok = s.auth.Allowed(ctx, ActionDeleteObject, resource) case *chaparralv1.NewUploaderRequest: - ok = s.auth.ActionAllowed(ctx, &user, CommitAction) + ok = s.auth.Allowed(ctx, ActionCommitObject, "*::*") case *chaparralv1.DeleteUploaderRequest: - ok = s.auth.ActionAllowed(ctx, &user, CommitAction) + ok = s.auth.Allowed(ctx, ActionCommitObject, "*::*") case *chaparralv1.GetUploaderRequest: - ok = s.auth.ActionAllowed(ctx, &user, CommitAction) + ok = s.auth.Allowed(ctx, ActionCommitObject, "*::*") case *chaparralv1.ListUploadersRequest: - ok = s.auth.ActionAllowed(ctx, &user, CommitAction) + ok = s.auth.Allowed(ctx, ActionCommitObject, "*::*") } if !ok { return nil, connect.NewError(connect.CodePermissionDenied, errors.New("API key insufficient permission")) diff --git a/server/commit_service_test.go b/server/commit_service_test.go index b0b8cdf..000d4f7 100644 --- a/server/commit_service_test.go +++ b/server/commit_service_test.go @@ -19,7 +19,6 @@ import ( chapv1connect "github.com/srerickson/chaparral/gen/chaparral/v1/chaparralv1connect" "github.com/srerickson/chaparral/internal/testutil" "github.com/srerickson/chaparral/server" - "github.com/srerickson/chaparral/server/store" "github.com/srerickson/ocfl-go" "golang.org/x/exp/slices" ) @@ -29,11 +28,11 @@ const size = 2_000_000 var _ chapv1connect.CommitServiceHandler = (*server.CommitService)(nil) func TestCommitServiceCommit(t *testing.T) { - test := func(t *testing.T, htc *http.Client, url string, store *store.StorageRoot) { - ctx := context.Background() - chap := chapv1connect.NewCommitServiceClient(htc, url) - alg := `sha256` - newUpResp, err := chap.NewUploader(ctx, connect.NewRequest(&chapv1.NewUploaderRequest{ + ctx := context.Background() + alg := `sha256` + test := func(t *testing.T, htc *http.Client, url string) { + commitSrv := chapv1connect.NewCommitServiceClient(htc, url) + newUpResp, err := commitSrv.NewUploader(ctx, connect.NewRequest(&chapv1.NewUploaderRequest{ DigestAlgorithms: []string{alg}, Description: "test commit", })) @@ -57,7 +56,7 @@ func TestCommitServiceCommit(t *testing.T) { newState[name] = upload.Digests[alg] } commitReq := &chapv1.CommitRequest{ - StorageRootId: "test", + StorageRootId: testutil.TestStoreID, DigestAlgorithm: alg, State: newState, Message: "commit v1", @@ -71,32 +70,59 @@ func TestCommitServiceCommit(t *testing.T) { }}, }, } - _, err = chap.Commit(ctx, connect.NewRequest(commitReq)) - be.NilErr(t, err) - // check object directly - obj, err := store.GetObjectVersion(ctx, "new-01", 0) + _, err = commitSrv.Commit(ctx, connect.NewRequest(commitReq)) be.NilErr(t, err) - gotPaths := 0 - for _, info := range obj.State { - gotPaths += len(info.Paths) + // // check object directly + // obj, err := store.GetObjectVersion(ctx, "new-01", 0) + // be.NilErr(t, err) + // gotPaths := 0 + // for _, info := range obj.State { + // gotPaths += len(info.Paths) + // } + // be.Equal(t, len(filenames), gotPaths) + // result, err := store.Validate(ctx) + // be.NilErr(t, err) + // be.NilErr(t, result.Err()) + } + + testUnauthorized := func(t *testing.T, htc *http.Client, url string) { + testutil.SetUserToken(htc, testutil.AnonUser) + commitSrv := chapv1connect.NewCommitServiceClient(htc, url) + alg := `sha256` + _, err := commitSrv.NewUploader(ctx, connect.NewRequest(&chapv1.NewUploaderRequest{ + DigestAlgorithms: []string{alg}, + Description: "test commit", + })) + be.True(t, err != nil) + var conErr *connect.Error + be.True(t, errors.As(err, &conErr)) + be.Equal(t, connect.CodePermissionDenied, conErr.Code()) + + commitReq := &chapv1.CommitRequest{ + StorageRootId: testutil.TestStoreID, + DigestAlgorithm: alg, + Message: "commit v1", + User: &chapv1.User{Name: "Test"}, + ObjectId: "new-01", } - be.Equal(t, len(filenames), gotPaths) - result, err := store.Validate(ctx) - be.NilErr(t, err) - be.NilErr(t, result.Err()) + _, err = commitSrv.Commit(ctx, connect.NewRequest(commitReq)) + be.True(t, err != nil) + be.True(t, errors.As(err, &conErr)) + be.Equal(t, connect.CodePermissionDenied, conErr.Code()) } - testutil.RunServiceTest(t, test) + + testutil.RunServiceTest(t, test, testUnauthorized) } func TestCommitServiceUploader(t *testing.T) { - testutil.RunServiceTest(t, func(t *testing.T, htc *http.Client, url string, store *store.StorageRoot) { + testutil.RunServiceTest(t, func(t *testing.T, htc *http.Client, url string) { times := 4 // concurrent uploaders wg := sync.WaitGroup{} wg.Add(times) for i := 0; i < times; i++ { go func() { defer wg.Done() - testCommitServiceUploader(t, htc, url, store) + testCommitServiceUploader(t, htc, url) }() } wg.Wait() @@ -104,7 +130,7 @@ func TestCommitServiceUploader(t *testing.T) { } // test creating an uploader, uploading to it, accessing it, and destroying it -func testCommitServiceUploader(t *testing.T, htc *http.Client, baseURL string, store *store.StorageRoot) { +func testCommitServiceUploader(t *testing.T, htc *http.Client, baseURL string) { ctx := context.Background() chapClient := chapv1connect.NewCommitServiceClient(htc, baseURL) // create new uploader diff --git a/server/store/storage_root_test.go b/server/store/storage_root_test.go index aa8d7c6..a83a830 100644 --- a/server/store/storage_root_test.go +++ b/server/store/storage_root_test.go @@ -21,7 +21,7 @@ func TestStorageRoot(t *testing.T) { roots := []*store.StorageRoot{ testutil.NewStoreTempDir(t), } - if testutil.WithS3() { + if testutil.S3Enabled() { roots = append(roots, testutil.NewStoreS3(t)) } for _, r := range roots {