From ca4211daefdc8b21a75babcde15ec6d81c2ad487 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 26 Feb 2024 18:20:40 +0100 Subject: [PATCH] feat: resources segregation by tenant --- Makefile | 13 +- go.mod | 6 +- go.sum | 4 + internal/auth/agent/authenticator.go | 17 ++- internal/auth/agent/user.go | 9 ++ internal/auth/error.go | 8 ++ internal/auth/middleware.go | 47 +++++++- internal/auth/thirdparty/authenticator.go | 36 ++++-- internal/auth/thirdparty/jwt.go | 12 +- internal/auth/thirdparty/user.go | 15 ++- internal/command/api/agent/claim.go | 51 ++++++++ internal/command/api/agent/count.go | 2 +- internal/command/api/agent/delete.go | 2 +- internal/command/api/agent/get.go | 2 +- internal/command/api/agent/query.go | 2 +- internal/command/api/agent/root.go | 1 + internal/command/api/agent/spec/delete.go | 2 +- internal/command/api/agent/spec/get.go | 2 +- internal/command/api/agent/spec/update.go | 2 +- internal/command/api/agent/update.go | 2 +- internal/command/api/agent/util.go | 10 +- internal/command/api/flag/flag.go | 4 +- internal/command/server/auth/create_token.go | 9 +- internal/config/server.go | 10 +- internal/datastore/agent.go | 1 + internal/datastore/agent_repository.go | 18 +++ internal/datastore/error.go | 1 + internal/datastore/spec.go | 2 + internal/datastore/sqlite/agent_repository.go | 112 +++++++++++++----- internal/datastore/sqlite/sql.go | 23 ++++ internal/datastore/tenant.go | 32 +++++ internal/format/json/writer.go | 38 ------ internal/format/prop.go | 18 --- internal/format/registry.go | 46 ------- internal/format/table/prop.go | 49 -------- internal/format/table/writer.go | 75 ------------ internal/format/table/writer_test.go | 86 -------------- internal/format/writer.go | 19 --- internal/imports/format/format_import.go | 4 +- internal/server/agent_api.go | 86 +++++++++++++- internal/server/authorization.go | 27 ++++- internal/server/server.go | 96 ++++++++++++--- migrations/sqlite/0000003_tenant.down.sql | 41 +++++++ migrations/sqlite/0000003_tenant.up.sql | 64 ++++++++++ pkg/client/claim_agent.go | 27 +++++ 45 files changed, 704 insertions(+), 429 deletions(-) create mode 100644 internal/auth/error.go create mode 100644 internal/command/api/agent/claim.go create mode 100644 internal/datastore/sqlite/sql.go create mode 100644 internal/datastore/tenant.go delete mode 100644 internal/format/json/writer.go delete mode 100644 internal/format/prop.go delete mode 100644 internal/format/registry.go delete mode 100644 internal/format/table/prop.go delete mode 100644 internal/format/table/writer.go delete mode 100644 internal/format/table/writer_test.go delete mode 100644 internal/format/writer.go create mode 100644 migrations/sqlite/0000003_tenant.down.sql create mode 100644 migrations/sqlite/0000003_tenant.up.sql create mode 100644 pkg/client/claim_agent.go diff --git a/Makefile b/Makefile index 16eb50a..5555d06 100644 --- a/Makefile +++ b/Makefile @@ -123,15 +123,18 @@ gitea-release: .mktools tools/gitea-release/bin/gitea-release.sh goreleaser chan tools/gitea-release/bin/gitea-release.sh .emissary-token: - $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token" + $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token --tenant '00000000-0000-0000-0000-000000000000'" AGENT_ID ?= 1 +claim-agent: + go run ./cmd/server api agent claim --agent-thumbprint $(shell go run ./cmd/agent agent show-thumbprint) + load-sample-specs: - cat misc/spec-samples/app.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com - cat misc/spec-samples/proxy.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com - cat misc/spec-samples/mdns.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com - cat misc/spec-samples/uci.emissary.cadoles.com.json | ./bin/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com + cat misc/spec-samples/app.emissary.cadoles.com.json | go run ./cmd/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com + cat misc/spec-samples/proxy.emissary.cadoles.com.json | go run ./cmd/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com + cat misc/spec-samples/mdns.emissary.cadoles.com.json | go run ./cmd/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com + cat misc/spec-samples/uci.emissary.cadoles.com.json | go run ./cmd/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com version: .mktools @echo $(MKT_PROJECT_VERSION) diff --git a/go.mod b/go.mod index 2e5a2af..dde5222 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/qri-io/jsonschema v0.2.1 github.com/urfave/cli/v2 v2.26.0 - gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 + gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.21.0 ) @@ -78,7 +78,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -123,4 +123,4 @@ require ( ) // replace forge.cadoles.com/arcad/edge => ../edge -replace github.com/allegro/bigcache/v3 v3.1.0 => github.com/Bornholm/bigcache v0.0.0-20231201111725-1ddf51584cad +// replace gitlab.com/wpetit/goweb => ../goweb diff --git a/go.sum b/go.sum index baefc7a..c7dd848 100644 --- a/go.sum +++ b/go.sum @@ -702,6 +702,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= @@ -1317,6 +1319,8 @@ github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxt gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07 h1:0V95X1cBpdj5zyOe6oGtn/BQHlRpV8WlL3eTs3jaxiA= gitlab.com/wpetit/goweb v0.0.0-20231215190137-4a8add1d3d07/go.mod h1:Nfr7aZPiSN6biFumhiHbh9k8A3rKQRzR+o0bVtv78UY= +gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88 h1:dsyRrmhp7fl/YaY1YIzz7lm9qfIFI5KpKNbXwuhTULA= +gitlab.com/wpetit/goweb v0.0.0-20240226160244-6b2826c79f88/go.mod h1:bg+TN16Rq2ygLQbB4VDSHQFNouAEzcy3AAutStehllA= go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= diff --git a/internal/auth/agent/authenticator.go b/internal/auth/agent/authenticator.go index 0cb6c70..e8af156 100644 --- a/internal/auth/agent/authenticator.go +++ b/internal/auth/agent/authenticator.go @@ -11,6 +11,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jws" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" ) const DefaultAcceptableSkew = 5 * time.Minute @@ -34,17 +35,19 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth token, err := jwt.Parse([]byte(rawToken), jwt.WithVerify(false)) if err != nil { - return nil, errors.WithStack(err) + logger.Debug(ctx, "could not parse jwt token", logger.CapturedE(errors.WithStack(err))) + return nil, errors.WithStack(auth.ErrUnauthenticated) } rawThumbprint, exists := token.Get(keyThumbprint) if !exists { - return nil, errors.Errorf("could not find '%s' claim", keyThumbprint) + return nil, errors.WithStack(auth.ErrUnauthenticated) } thumbrint, ok := rawThumbprint.(string) if !ok { - return nil, errors.Errorf("unexpected '%s' claim value: '%v'", keyThumbprint, rawThumbprint) + logger.Debug(ctx, "unexpected claim value", logger.F("claim", rawThumbprint), logger.F("value", rawThumbprint)) + return nil, errors.WithStack(auth.ErrUnauthenticated) } agents, _, err := a.repo.Query( @@ -57,7 +60,8 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth } if len(agents) != 1 { - return nil, errors.Errorf("unexpected number of found agents: '%d'", len(agents)) + logger.Debug(ctx, "unexpected number of found agents", logger.F("total", len(agents))) + return nil, errors.WithStack(auth.ErrUnauthenticated) } agent, err := a.repo.Get( @@ -75,14 +79,15 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth jwt.WithAcceptableSkew(a.acceptableSkew), ) if err != nil { - return nil, errors.WithStack(err) + logger.Error(ctx, "could not parse jwt", logger.CapturedE(errors.WithStack(err))) + return nil, errors.WithStack(auth.ErrUnauthenticated) } contactedAt := time.Now() agent, err = a.repo.Update(ctx, agent.ID, datastore.WithAgentUpdateContactedAt(contactedAt)) if err != nil { - return nil, errors.WithStack(err) + return nil, errors.WithStack(auth.ErrUnauthenticated) } user := &User{ diff --git a/internal/auth/agent/user.go b/internal/auth/agent/user.go index 8f23e6f..94a11b9 100644 --- a/internal/auth/agent/user.go +++ b/internal/auth/agent/user.go @@ -16,6 +16,15 @@ func (u *User) Subject() string { return fmt.Sprintf("agent-%d", u.agent.ID) } +// Subject implements auth.User +func (u *User) Tenant() datastore.TenantID { + if u.agent.TenantID == nil { + return "" + } + + return *u.agent.TenantID +} + func (u *User) Agent() *datastore.Agent { return u.agent } diff --git a/internal/auth/error.go b/internal/auth/error.go new file mode 100644 index 0000000..b580f93 --- /dev/null +++ b/internal/auth/error.go @@ -0,0 +1,8 @@ +package auth + +import "github.com/pkg/errors" + +var ( + ErrUnauthenticated = errors.New("unauthenticated") + ErrUnauthorized = errors.New(("unauthorized")) +) diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index c62ebb5..c180f8f 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -4,6 +4,7 @@ import ( "context" "net/http" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" "github.com/pkg/errors" "gitlab.com/wpetit/goweb/api" "gitlab.com/wpetit/goweb/logger" @@ -29,10 +30,9 @@ func CtxUser(ctx context.Context) (User, error) { return user, nil } -var ErrUnauthenticated = errors.New("unauthenticated") - type User interface { Subject() string + Tenant() datastore.TenantID } type Authenticator interface { @@ -49,11 +49,12 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler err error ) + var errs []error + for _, auth := range authenticators { user, err = auth.Authenticate(ctx, r) if err != nil { - logger.Debug(ctx, "could not authenticate request", logger.E(errors.WithStack(err))) - + errs = append(errs, errors.WithStack(err)) continue } @@ -63,9 +64,22 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler } if user == nil { - api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil) + isUnauthorized, isUnauthenticated, isUnknown := checkErrors(errs) - return + switch { + case isUnauthorized && !isUnknown: + api.ErrorResponse(w, http.StatusForbidden, api.ErrCodeForbidden, nil) + return + case isUnauthenticated && !isUnknown: + api.ErrorResponse(w, http.StatusForbidden, api.ErrCodeForbidden, nil) + return + case isUnknown: + api.ErrorResponse(w, http.StatusInternalServerError, api.ErrCodeUnknownError, nil) + return + default: + api.ErrorResponse(w, http.StatusUnauthorized, ErrCodeUnauthorized, nil) + return + } } ctx = logger.With(ctx, logger.F("user", user.Subject())) @@ -77,3 +91,24 @@ func Middleware(authenticators ...Authenticator) func(http.Handler) http.Handler return http.HandlerFunc(fn) } } + +func checkErrors(errs []error) (isUnauthorized bool, isUnauthenticated bool, isUnknown bool) { + isUnauthenticated = false + isUnauthorized = false + isUnknown = false + + for _, e := range errs { + switch { + case errors.Is(e, ErrUnauthorized): + isUnauthorized = true + continue + case errors.Is(e, ErrUnauthenticated): + isUnauthenticated = true + continue + default: + isUnknown = true + } + } + + return +} diff --git a/internal/auth/thirdparty/authenticator.go b/internal/auth/thirdparty/authenticator.go index 95af1e5..803e638 100644 --- a/internal/auth/thirdparty/authenticator.go +++ b/internal/auth/thirdparty/authenticator.go @@ -7,21 +7,25 @@ import ( "time" "forge.cadoles.com/Cadoles/emissary/internal/auth" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/jwk" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" ) const DefaultAcceptableSkew = 5 * time.Minute type ( - GetKeySet func(context.Context) (jwk.Set, error) - GetTokenRole func(context.Context, jwt.Token) (string, error) + GetKeySet func(context.Context) (jwk.Set, error) + GetTokenRole func(context.Context, jwt.Token) (string, error) + GetTokenTenant func(context.Context, jwt.Token) (string, error) ) type Authenticator struct { getKeySet GetKeySet getTokenRole GetTokenRole + getTokenTenant GetTokenTenant acceptableSkew time.Duration } @@ -44,29 +48,45 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth token, err := parseToken(ctx, keys, rawToken, a.acceptableSkew) if err != nil { - return nil, errors.WithStack(err) + logger.Debug(ctx, "could not parse jwt token", logger.CapturedE(errors.WithStack(err))) + return nil, errors.WithStack(auth.ErrUnauthenticated) } rawRole, err := a.getTokenRole(ctx, token) if err != nil { - return nil, errors.WithStack(err) + logger.Debug(ctx, "could not retrieve token role", logger.CapturedE(errors.WithStack(err))) + return nil, errors.WithStack(auth.ErrUnauthenticated) } if !isValidRole(rawRole) { - return nil, errors.Errorf("invalid role '%s'", rawRole) + return nil, errors.WithStack(auth.ErrUnauthorized) + } + + rawTenantID, err := a.getTokenTenant(ctx, token) + if err != nil { + logger.Debug(ctx, "could not retrieve token tenant", logger.CapturedE(errors.WithStack(err))) + return nil, errors.WithStack(auth.ErrUnauthenticated) + } + + tenantID, err := datastore.ParseTenantID(rawTenantID) + if err != nil { + logger.Debug(ctx, "could not retrieve token tenant", logger.CapturedE(errors.WithStack(err))) + return nil, errors.WithStack(auth.ErrUnauthenticated) } user := &User{ - subject: token.Subject(), - role: Role(rawRole), + subject: token.Subject(), + role: Role(rawRole), + tenantID: tenantID, } return user, nil } -func NewAuthenticator(getKeySet GetKeySet, getTokenRole GetTokenRole, acceptableSkew time.Duration) *Authenticator { +func NewAuthenticator(getKeySet GetKeySet, getTokenRole GetTokenRole, getTokenTenant GetTokenTenant, acceptableSkew time.Duration) *Authenticator { return &Authenticator{ getTokenRole: getTokenRole, + getTokenTenant: getTokenTenant, getKeySet: getKeySet, acceptableSkew: acceptableSkew, } diff --git a/internal/auth/thirdparty/jwt.go b/internal/auth/thirdparty/jwt.go index 1e76465..b17d58e 100644 --- a/internal/auth/thirdparty/jwt.go +++ b/internal/auth/thirdparty/jwt.go @@ -4,6 +4,7 @@ import ( "context" "time" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/jwk" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jws" @@ -26,9 +27,12 @@ func parseToken(ctx context.Context, keys jwk.Set, rawToken string, acceptableSk return token, nil } -const DefaultRoleKey string = "role" +const ( + DefaultRoleKey string = "role" + DefaultTenantKey string = "tenant" +) -func GenerateToken(ctx context.Context, key jwk.Key, subject string, role Role) (string, error) { +func GenerateToken(ctx context.Context, key jwk.Key, tenant datastore.TenantID, subject string, role Role) (string, error) { token := jwt.New() if err := token.Set(jwt.SubjectKey, subject); err != nil { @@ -39,6 +43,10 @@ func GenerateToken(ctx context.Context, key jwk.Key, subject string, role Role) return "", errors.WithStack(err) } + if err := token.Set(DefaultTenantKey, tenant); err != nil { + return "", errors.WithStack(err) + } + now := time.Now().UTC() if err := token.Set(jwt.NotBeforeKey, now); err != nil { diff --git a/internal/auth/thirdparty/user.go b/internal/auth/thirdparty/user.go index db26da6..ea2eae9 100644 --- a/internal/auth/thirdparty/user.go +++ b/internal/auth/thirdparty/user.go @@ -1,6 +1,9 @@ package thirdparty -import "forge.cadoles.com/Cadoles/emissary/internal/auth" +import ( + "forge.cadoles.com/Cadoles/emissary/internal/auth" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" +) type Role string @@ -16,8 +19,9 @@ func isValidRole(r string) bool { } type User struct { - subject string - role Role + subject string + tenantID datastore.TenantID + role Role } // Subject implements auth.User @@ -25,6 +29,11 @@ func (u *User) Subject() string { return u.subject } +// Tenant implements auth.User +func (u *User) Tenant() datastore.TenantID { + return u.tenantID +} + func (u *User) Role() Role { return u.role } diff --git a/internal/command/api/agent/claim.go b/internal/command/api/agent/claim.go new file mode 100644 index 0000000..4667aab --- /dev/null +++ b/internal/command/api/agent/claim.go @@ -0,0 +1,51 @@ +package agent + +import ( + "os" + + "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" + "forge.cadoles.com/Cadoles/emissary/pkg/client" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" +) + +func ClaimCommand() *cli.Command { + return &cli.Command{ + Name: "claim", + Usage: "Claim agent", + Flags: clientFlag.ComposeFlags( + &cli.StringFlag{ + Name: "agent-thumbprint", + Value: "", + Required: true, + }, + ), + Action: func(ctx *cli.Context) error { + baseFlags := clientFlag.GetBaseFlags(ctx) + + token, err := clientFlag.GetToken(baseFlags) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + agentThumbprint := ctx.String("agent-thumbprint") + + client := client.New(baseFlags.ServerURL, client.WithToken(token)) + + agent, err := client.ClaimAgent(ctx.Context, agentThumbprint) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + hints := agentHints(baseFlags.OutputMode) + + if err := format.Write(baseFlags.Format, os.Stdout, hints, agent); err != nil { + return errors.WithStack(err) + } + + return nil + }, + } +} diff --git a/internal/command/api/agent/count.go b/internal/command/api/agent/count.go index 42834a4..8d57c50 100644 --- a/internal/command/api/agent/count.go +++ b/internal/command/api/agent/count.go @@ -5,10 +5,10 @@ import ( "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" - "forge.cadoles.com/Cadoles/emissary/internal/format" "forge.cadoles.com/Cadoles/emissary/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" ) func CountCommand() *cli.Command { diff --git a/internal/command/api/agent/delete.go b/internal/command/api/agent/delete.go index 3cb230f..b260666 100644 --- a/internal/command/api/agent/delete.go +++ b/internal/command/api/agent/delete.go @@ -7,10 +7,10 @@ import ( "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" "forge.cadoles.com/Cadoles/emissary/internal/datastore" - "forge.cadoles.com/Cadoles/emissary/internal/format" "forge.cadoles.com/Cadoles/emissary/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" ) func DeleteCommand() *cli.Command { diff --git a/internal/command/api/agent/get.go b/internal/command/api/agent/get.go index bba75df..18a4349 100644 --- a/internal/command/api/agent/get.go +++ b/internal/command/api/agent/get.go @@ -6,10 +6,10 @@ import ( agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" - "forge.cadoles.com/Cadoles/emissary/internal/format" "forge.cadoles.com/Cadoles/emissary/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" ) func GetCommand() *cli.Command { diff --git a/internal/command/api/agent/query.go b/internal/command/api/agent/query.go index d763525..4ccd76b 100644 --- a/internal/command/api/agent/query.go +++ b/internal/command/api/agent/query.go @@ -6,10 +6,10 @@ import ( "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" "forge.cadoles.com/Cadoles/emissary/internal/datastore" - "forge.cadoles.com/Cadoles/emissary/internal/format" "forge.cadoles.com/Cadoles/emissary/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" ) func QueryCommand() *cli.Command { diff --git a/internal/command/api/agent/root.go b/internal/command/api/agent/root.go index d238bd6..8494be7 100644 --- a/internal/command/api/agent/root.go +++ b/internal/command/api/agent/root.go @@ -15,6 +15,7 @@ func Root() *cli.Command { UpdateCommand(), GetCommand(), DeleteCommand(), + ClaimCommand(), spec.Root(), }, } diff --git a/internal/command/api/agent/spec/delete.go b/internal/command/api/agent/spec/delete.go index e9762e4..0e8351b 100644 --- a/internal/command/api/agent/spec/delete.go +++ b/internal/command/api/agent/spec/delete.go @@ -6,11 +6,11 @@ import ( agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" - "forge.cadoles.com/Cadoles/emissary/internal/format" "forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" ) func DeleteCommand() *cli.Command { diff --git a/internal/command/api/agent/spec/get.go b/internal/command/api/agent/spec/get.go index 460f239..3a7d250 100644 --- a/internal/command/api/agent/spec/get.go +++ b/internal/command/api/agent/spec/get.go @@ -6,10 +6,10 @@ import ( agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" - "forge.cadoles.com/Cadoles/emissary/internal/format" "forge.cadoles.com/Cadoles/emissary/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" ) func GetCommand() *cli.Command { diff --git a/internal/command/api/agent/spec/update.go b/internal/command/api/agent/spec/update.go index fb359c4..983508b 100644 --- a/internal/command/api/agent/spec/update.go +++ b/internal/command/api/agent/spec/update.go @@ -7,12 +7,12 @@ import ( agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" - "forge.cadoles.com/Cadoles/emissary/internal/format" "forge.cadoles.com/Cadoles/emissary/internal/spec" "forge.cadoles.com/Cadoles/emissary/pkg/client" jsonpatch "github.com/evanphx/json-patch/v5" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" ) func UpdateCommand() *cli.Command { diff --git a/internal/command/api/agent/update.go b/internal/command/api/agent/update.go index 1152819..dfe048c 100644 --- a/internal/command/api/agent/update.go +++ b/internal/command/api/agent/update.go @@ -6,10 +6,10 @@ import ( agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/flag" "forge.cadoles.com/Cadoles/emissary/internal/command/api/apierr" clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" - "forge.cadoles.com/Cadoles/emissary/internal/format" "forge.cadoles.com/Cadoles/emissary/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" ) func UpdateCommand() *cli.Command { diff --git a/internal/command/api/agent/util.go b/internal/command/api/agent/util.go index 80b9ad5..478e569 100644 --- a/internal/command/api/agent/util.go +++ b/internal/command/api/agent/util.go @@ -1,17 +1,21 @@ package agent -import "forge.cadoles.com/Cadoles/emissary/internal/format" +import ( + "gitlab.com/wpetit/goweb/cli/format" + "gitlab.com/wpetit/goweb/cli/format/table" +) func agentHints(outputMode format.OutputMode) format.Hints { return format.Hints{ OutputMode: outputMode, Props: []format.Prop{ format.NewProp("ID", "ID"), + format.NewProp("TenantID", "Tenant", table.WithCompactModeMaxColumnWidth(8)), format.NewProp("Label", "Label"), format.NewProp("Thumbprint", "Thumbprint"), format.NewProp("Status", "Status"), - format.NewProp("ContactedAt", "ContactedAt"), - format.NewProp("UpdatedAt", "UpdatedAt"), + format.NewProp("ContactedAt", "ContactedAt", table.WithCompactModeMaxColumnWidth(20)), + format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)), }, } } diff --git a/internal/command/api/flag/flag.go b/internal/command/api/flag/flag.go index 270aa97..c463933 100644 --- a/internal/command/api/flag/flag.go +++ b/internal/command/api/flag/flag.go @@ -5,10 +5,10 @@ import ( "os" "strings" - "forge.cadoles.com/Cadoles/emissary/internal/format" - "forge.cadoles.com/Cadoles/emissary/internal/format/table" "github.com/pkg/errors" "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" + "gitlab.com/wpetit/goweb/cli/format/table" ) const ( diff --git a/internal/command/server/auth/create_token.go b/internal/command/server/auth/create_token.go index 8c8554d..f369a14 100644 --- a/internal/command/server/auth/create_token.go +++ b/internal/command/server/auth/create_token.go @@ -8,6 +8,7 @@ import ( "forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" "forge.cadoles.com/Cadoles/emissary/internal/command/common" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/jwk" "github.com/lithammer/shortuuid/v4" "github.com/pkg/errors" @@ -29,6 +30,11 @@ func CreateTokenCommand() *cli.Command { Usage: "associate `SUBJECT` to the token", Value: fmt.Sprintf("user-%s", shortuuid.New()), }, + &cli.StringFlag{ + Name: "tenant", + Usage: "associate `TENANT` to the token", + Required: true, + }, &cli.StringFlag{ Name: "output", Aliases: []string{"o"}, @@ -44,6 +50,7 @@ func CreateTokenCommand() *cli.Command { } subject := ctx.String("subject") + tenant := ctx.String("tenant") role := ctx.String("role") output := ctx.String("output") @@ -57,7 +64,7 @@ func CreateTokenCommand() *cli.Command { return errors.WithStack(err) } - token, err := thirdparty.GenerateToken(ctx.Context, key, subject, thirdparty.Role(role)) + token, err := thirdparty.GenerateToken(ctx.Context, key, datastore.TenantID(tenant), subject, thirdparty.Role(role)) if err != nil { return errors.WithStack(err) } diff --git a/internal/config/server.go b/internal/config/server.go index 8b192fb..b3b3510 100644 --- a/internal/config/server.go +++ b/internal/config/server.go @@ -23,9 +23,10 @@ func NewDefaultServerConfig() ServerConfig { } type AuthConfig struct { - Local *LocalAuthConfig `yaml:"local"` - Remote *RemoteAuthConfig `yaml:"remote"` - RoleExtractionRules []string `yaml:"roleExtractionRules"` + Local *LocalAuthConfig `yaml:"local"` + Remote *RemoteAuthConfig `yaml:"remote"` + RoleExtractionRules []string `yaml:"roleExtractionRules"` + TenantExtractionRules []string `yaml:"tenantExtractionRules"` } func NewDefaultAuthConfig() AuthConfig { @@ -37,6 +38,9 @@ func NewDefaultAuthConfig() AuthConfig { RoleExtractionRules: []string{ fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", thirdparty.DefaultRoleKey, thirdparty.DefaultRoleKey), }, + TenantExtractionRules: []string{ + fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", thirdparty.DefaultTenantKey, thirdparty.DefaultTenantKey), + }, } } diff --git a/internal/datastore/agent.go b/internal/datastore/agent.go index 621f226..bae6bfb 100644 --- a/internal/datastore/agent.go +++ b/internal/datastore/agent.go @@ -29,6 +29,7 @@ type Agent struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` ContactedAt *time.Time `json:"contactedAt,omitempty"` + TenantID *TenantID `json:"tenantId"` } type SerializableKeySet struct { diff --git a/internal/datastore/agent_repository.go b/internal/datastore/agent_repository.go index 640626d..311d9cc 100644 --- a/internal/datastore/agent_repository.go +++ b/internal/datastore/agent_repository.go @@ -9,6 +9,10 @@ import ( type AgentRepository interface { Create(ctx context.Context, thumbprint string, keySet jwk.Set, metadata map[string]any) (*Agent, error) + + Attach(ctx context.Context, tenantID TenantID, agentID AgentID) (*Agent, error) + Detach(ctx context.Context, agentID AgentID) (*Agent, error) + Get(ctx context.Context, id AgentID) (*Agent, error) Update(ctx context.Context, id AgentID, updates ...AgentUpdateOptionFunc) (*Agent, error) Query(ctx context.Context, opts ...AgentQueryOptionFunc) ([]*Agent, int, error) @@ -25,6 +29,7 @@ type AgentQueryOptions struct { Limit *int Offset *int IDs []AgentID + TenantIDs []TenantID Thumbprints []string Metadata *map[string]any Statuses []AgentStatus @@ -54,6 +59,12 @@ func WithAgentQueryID(ids ...AgentID) AgentQueryOptionFunc { } } +func WithAgentQueryTenantID(ids ...TenantID) AgentQueryOptionFunc { + return func(opts *AgentQueryOptions) { + opts.TenantIDs = ids + } +} + func WithAgentQueryStatus(statuses ...AgentStatus) AgentQueryOptionFunc { return func(opts *AgentQueryOptions) { opts.Statuses = statuses @@ -75,6 +86,13 @@ type AgentUpdateOptions struct { Metadata *map[string]any KeySet *jwk.Set Thumbprint *string + TenantID *TenantID +} + +func WithAgentUpdateTenant(id TenantID) AgentUpdateOptionFunc { + return func(opts *AgentUpdateOptions) { + opts.TenantID = &id + } } func WithAgentUpdateStatus(status AgentStatus) AgentUpdateOptionFunc { diff --git a/internal/datastore/error.go b/internal/datastore/error.go index 99c0050..7d7d4a3 100644 --- a/internal/datastore/error.go +++ b/internal/datastore/error.go @@ -6,4 +6,5 @@ var ( ErrNotFound = errors.New("not found") ErrAlreadyExist = errors.New("already exist") ErrUnexpectedRevision = errors.New("unexpected revision") + ErrAlreadyAttached = errors.New("already attached") ) diff --git a/internal/datastore/spec.go b/internal/datastore/spec.go index b4b93c8..be417c7 100644 --- a/internal/datastore/spec.go +++ b/internal/datastore/spec.go @@ -15,6 +15,8 @@ type Spec struct { Revision int `json:"revision"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` + TenantID TenantID `json:"tenantId"` + AgentID AgentID `json:"agentId"` } func (s *Spec) SpecName() spec.Name { diff --git a/internal/datastore/sqlite/agent_repository.go b/internal/datastore/sqlite/agent_repository.go index 9b22100..9857e3d 100644 --- a/internal/datastore/sqlite/agent_repository.go +++ b/internal/datastore/sqlite/agent_repository.go @@ -20,6 +20,75 @@ type AgentRepository struct { sqliteBusyRetryMaxAttempts int } +// Attach implements datastore.AgentRepository. +func (r *AgentRepository) Attach(ctx context.Context, tenantID datastore.TenantID, agentID datastore.AgentID) (*datastore.Agent, error) { + var agent datastore.Agent + + err := r.withTxRetry(ctx, func(tx *sql.Tx) error { + query := `SELECT count(id), tenant_id FROM agents WHERE id = $1` + row := tx.QueryRowContext(ctx, query, agentID) + + var ( + count int + attachedTenantID *datastore.TenantID + ) + + if err := row.Scan(&count, &attachedTenantID); err != nil { + return errors.WithStack(err) + } + + if count == 0 { + return errors.WithStack(datastore.ErrNotFound) + } + + if attachedTenantID != nil { + return errors.WithStack(datastore.ErrAlreadyAttached) + } + + now := time.Now().UTC() + + query = ` + UPDATE agents SET tenant_id = $1, updated_at = $2 + RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id" + ` + + row = tx.QueryRowContext( + ctx, query, + tenantID, + now, + ) + + metadata := JSONMap{} + var rawKeySet []byte + + err := row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID) + if err != nil { + return errors.WithStack(err) + } + + agent.Metadata = metadata + + keySet, err := jwk.Parse(rawKeySet) + if err != nil { + return errors.WithStack(err) + } + + agent.KeySet = &datastore.SerializableKeySet{keySet} + + return nil + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return &agent, nil +} + +// Detach implements datastore.AgentRepository. +func (*AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) { + panic("unimplemented") +} + // DeleteSpec implements datastore.AgentRepository. func (r *AgentRepository) DeleteSpec(ctx context.Context, agentID datastore.AgentID, name string) error { err := r.withTxRetry(ctx, func(tx *sql.Tx) error { @@ -170,7 +239,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer count := 0 err := r.withTxRetry(ctx, func(tx *sql.Tx) error { - query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at FROM agents` + query := `SELECT id, label, thumbprint, status, contacted_at, created_at, updated_at, tenant_id FROM agents` limit := 10 if options.Limit != nil { @@ -193,6 +262,13 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer args = append(args, newArgs...) } + if options.TenantIDs != nil && len(options.TenantIDs) > 0 { + filter, newArgs, newParamIndex := inFilter("tenant_id", paramIndex, options.TenantIDs) + filters += filter + paramIndex = newParamIndex + args = append(args, newArgs...) + } + if options.Thumbprints != nil && len(options.Thumbprints) > 0 { if filters != "" { filters += " AND " @@ -240,7 +316,7 @@ func (r *AgentRepository) Query(ctx context.Context, opts ...datastore.AgentQuer metadata := JSONMap{} contactedAt := sql.NullTime{} - if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { + if err := rows.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil { return errors.WithStack(err) } @@ -293,7 +369,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet query = ` INSERT INTO agents (thumbprint, keyset, metadata, status, created_at, updated_at) VALUES($1, $2, $3, $4, $5, $5) - RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at" + RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id" ` rawKeySet, err := json.Marshal(keySet) @@ -308,7 +384,7 @@ func (r *AgentRepository) Create(ctx context.Context, thumbprint string, keySet metadata := JSONMap{} - err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt) + err = row.Scan(&agent.ID, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID) if err != nil { return errors.WithStack(err) } @@ -363,7 +439,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas err := r.withTxRetry(ctx, func(tx *sql.Tx) error { query := ` - SELECT "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at" + SELECT "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at", "tenant_id" FROM agents WHERE id = $1 ` @@ -374,7 +450,7 @@ func (r *AgentRepository) Get(ctx context.Context, id datastore.AgentID) (*datas contactedAt := sql.NullTime{} var rawKeySet []byte - if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { + if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil { if errors.Is(err, sql.ErrNoRows) { return datastore.ErrNotFound } @@ -476,7 +552,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts query += ` WHERE id = $1 - RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at" + RETURNING "id", "label", "thumbprint", "keyset", "metadata", "status", "contacted_at", "created_at", "updated_at", "tenant_id" ` logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args)) @@ -487,7 +563,7 @@ func (r *AgentRepository) Update(ctx context.Context, id datastore.AgentID, opts contactedAt := sql.NullTime{} var rawKeySet []byte - if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt); err != nil { + if err := row.Scan(&agent.ID, &agent.Label, &agent.Thumbprint, &rawKeySet, &metadata, &agent.Status, &contactedAt, &agent.CreatedAt, &agent.UpdatedAt, &agent.TenantID); err != nil { if errors.Is(err, sql.ErrNoRows) { return datastore.ErrNotFound } @@ -622,23 +698,3 @@ func NewAgentRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *AgentReposi } var _ datastore.AgentRepository = &AgentRepository{} - -func inFilter[T any](column string, paramIndex int, items []T) (string, []any, int) { - args := make([]any, 0, len(items)) - filter := fmt.Sprintf("%s in (", column) - - for idx, item := range items { - if idx != 0 { - filter += "," - } - - filter += fmt.Sprintf("$%d", paramIndex) - paramIndex++ - - args = append(args, item) - } - - filter += ")" - - return filter, args, paramIndex -} diff --git a/internal/datastore/sqlite/sql.go b/internal/datastore/sqlite/sql.go new file mode 100644 index 0000000..1706043 --- /dev/null +++ b/internal/datastore/sqlite/sql.go @@ -0,0 +1,23 @@ +package sqlite + +import "fmt" + +func inFilter[T any](column string, paramIndex int, items []T) (string, []any, int) { + args := make([]any, 0, len(items)) + filter := fmt.Sprintf("%s in (", column) + + for idx, item := range items { + if idx != 0 { + filter += "," + } + + filter += fmt.Sprintf("$%d", paramIndex) + paramIndex++ + + args = append(args, item) + } + + filter += ")" + + return filter, args, paramIndex +} diff --git a/internal/datastore/tenant.go b/internal/datastore/tenant.go new file mode 100644 index 0000000..e8c2f68 --- /dev/null +++ b/internal/datastore/tenant.go @@ -0,0 +1,32 @@ +package datastore + +import ( + "time" + + "github.com/google/uuid" + "github.com/pkg/errors" +) + +const DefaultTenantID TenantID = "00000000-0000-0000-0000-000000000000" + +type TenantID string + +func NewTenantID() TenantID { + return TenantID(uuid.New().String()) +} + +func ParseTenantID(raw string) (TenantID, error) { + uuid, err := uuid.Parse(raw) + if err != nil { + return "", errors.WithStack(err) + } + + return TenantID(uuid.String()), nil +} + +type Tenant struct { + ID TenantID `json:"id"` + Label string `json:"label"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/internal/format/json/writer.go b/internal/format/json/writer.go deleted file mode 100644 index e7da083..0000000 --- a/internal/format/json/writer.go +++ /dev/null @@ -1,38 +0,0 @@ -package json - -import ( - "encoding/json" - "io" - - "forge.cadoles.com/Cadoles/emissary/internal/format" - "github.com/pkg/errors" -) - -const Format format.Format = "json" - -func init() { - format.Register(Format, NewWriter()) -} - -type Writer struct{} - -// Format implements format.Writer. -func (*Writer) Write(writer io.Writer, hints format.Hints, data ...any) error { - encoder := json.NewEncoder(writer) - - if hints.OutputMode == format.OutputModeWide { - encoder.SetIndent("", " ") - } - - if err := encoder.Encode(data); err != nil { - return errors.WithStack(err) - } - - return nil -} - -func NewWriter() *Writer { - return &Writer{} -} - -var _ format.Writer = &Writer{} diff --git a/internal/format/prop.go b/internal/format/prop.go deleted file mode 100644 index 13c0c49..0000000 --- a/internal/format/prop.go +++ /dev/null @@ -1,18 +0,0 @@ -package format - -type Prop struct { - name string - label string -} - -func (p *Prop) Name() string { - return p.name -} - -func (p *Prop) Label() string { - return p.label -} - -func NewProp(name, label string) Prop { - return Prop{name, label} -} diff --git a/internal/format/registry.go b/internal/format/registry.go deleted file mode 100644 index bfe1e58..0000000 --- a/internal/format/registry.go +++ /dev/null @@ -1,46 +0,0 @@ -package format - -import ( - "io" - - "github.com/pkg/errors" -) - -type Format string - -type Registry map[Format]Writer - -var defaultRegistry = Registry{} - -var ErrUnknownFormat = errors.New("unknown format") - -func Write(format Format, writer io.Writer, hints Hints, data ...any) error { - formatWriter, exists := defaultRegistry[format] - if !exists { - return errors.WithStack(ErrUnknownFormat) - } - - if hints.OutputMode == "" { - hints.OutputMode = OutputModeCompact - } - - if err := formatWriter.Write(writer, hints, data...); err != nil { - return errors.WithStack(err) - } - - return nil -} - -func Available() []Format { - formats := make([]Format, 0, len(defaultRegistry)) - - for f := range defaultRegistry { - formats = append(formats, f) - } - - return formats -} - -func Register(format Format, writer Writer) { - defaultRegistry[format] = writer -} diff --git a/internal/format/table/prop.go b/internal/format/table/prop.go deleted file mode 100644 index 1501faa..0000000 --- a/internal/format/table/prop.go +++ /dev/null @@ -1,49 +0,0 @@ -package table - -import ( - "encoding/json" - "fmt" - "reflect" - - "forge.cadoles.com/Cadoles/emissary/internal/format" - "github.com/pkg/errors" -) - -func getProps(d any) []format.Prop { - props := make([]format.Prop, 0) - - v := reflect.Indirect(reflect.ValueOf(d)) - typeOf := v.Type() - - for i := 0; i < v.NumField(); i++ { - name := typeOf.Field(i).Name - props = append(props, format.NewProp(name, name)) - } - - return props -} - -func getFieldValue(obj any, name string) string { - v := reflect.Indirect(reflect.ValueOf(obj)) - - fieldValue := v.FieldByName(name) - - switch fieldValue.Kind() { - case reflect.Map: - fallthrough - case reflect.Struct: - fallthrough - case reflect.Slice: - fallthrough - case reflect.Interface: - json, err := json.Marshal(fieldValue.Interface()) - if err != nil { - panic(errors.WithStack(err)) - } - - return string(json) - - default: - return fmt.Sprintf("%v", fieldValue.Interface()) - } -} diff --git a/internal/format/table/writer.go b/internal/format/table/writer.go deleted file mode 100644 index bbf7227..0000000 --- a/internal/format/table/writer.go +++ /dev/null @@ -1,75 +0,0 @@ -package table - -import ( - "io" - - "forge.cadoles.com/Cadoles/emissary/internal/format" - "github.com/jedib0t/go-pretty/v6/table" -) - -const Format format.Format = "table" - -const DefaultCompactModeMaxColumnWidth = 30 - -func init() { - format.Register(Format, NewWriter(DefaultCompactModeMaxColumnWidth)) -} - -type Writer struct { - compactModeMaxColumnWidth int -} - -// Write implements format.Writer. -func (w *Writer) Write(writer io.Writer, hints format.Hints, data ...any) error { - t := table.NewWriter() - - t.SetOutputMirror(writer) - - var props []format.Prop - - if hints.Props != nil { - props = hints.Props - } else { - if len(data) > 0 { - props = getProps(data[0]) - } else { - props = make([]format.Prop, 0) - } - } - - labels := table.Row{} - - for _, p := range props { - labels = append(labels, p.Label()) - } - - t.AppendHeader(labels) - - isCompactMode := hints.OutputMode == format.OutputModeCompact - - for _, d := range data { - row := table.Row{} - - for _, p := range props { - value := getFieldValue(d, p.Name()) - - if isCompactMode && len(value) > w.compactModeMaxColumnWidth { - value = value[:w.compactModeMaxColumnWidth] + "..." - } - - row = append(row, value) - } - - t.AppendRow(row) - } - - t.Render() - - return nil -} - -func NewWriter(compactModeMaxColumnWidth int) *Writer { - return &Writer{compactModeMaxColumnWidth} -} - -var _ format.Writer = &Writer{} diff --git a/internal/format/table/writer_test.go b/internal/format/table/writer_test.go deleted file mode 100644 index b23dae9..0000000 --- a/internal/format/table/writer_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package table - -import ( - "bytes" - "strings" - "testing" - - "forge.cadoles.com/Cadoles/emissary/internal/format" - "github.com/pkg/errors" -) - -type dummyItem struct { - MyString string - MyInt int - MySub subItem -} - -type subItem struct { - MyBool bool -} - -var dummyItems = []any{ - dummyItem{ - MyString: "Foo", - MyInt: 1, - MySub: subItem{ - MyBool: false, - }, - }, - dummyItem{ - MyString: "Bar", - MyInt: 0, - MySub: subItem{ - MyBool: true, - }, - }, -} - -func TestWriterNoHints(t *testing.T) { - var buf bytes.Buffer - - writer := NewWriter(DefaultCompactModeMaxColumnWidth) - - if err := writer.Write(&buf, format.Hints{}, dummyItems...); err != nil { - t.Fatalf("%+v", errors.WithStack(err)) - } - - expected := `+----------+-------+------------------+ -| MYSTRING | MYINT | MYSUB | -+----------+-------+------------------+ -| Foo | 1 | {"MyBool":false} | -| Bar | 0 | {"MyBool":true} | -+----------+-------+------------------+` - - if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g { - t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g) - } -} - -func TestWriterWithPropHints(t *testing.T) { - var buf bytes.Buffer - - writer := NewWriter(DefaultCompactModeMaxColumnWidth) - - hints := format.Hints{ - Props: []format.Prop{ - format.NewProp("MyString", "MyString"), - format.NewProp("MyInt", "MyInt"), - }, - } - - if err := writer.Write(&buf, hints, dummyItems...); err != nil { - t.Fatalf("%+v", errors.WithStack(err)) - } - - expected := `+----------+-------+ -| MYSTRING | MYINT | -+----------+-------+ -| Foo | 1 | -| Bar | 0 | -+----------+-------+` - - if e, g := strings.TrimSpace(expected), strings.TrimSpace(buf.String()); e != g { - t.Errorf("buf.String(): expected \n%v\ngot\n%v", e, g) - } -} diff --git a/internal/format/writer.go b/internal/format/writer.go deleted file mode 100644 index bfc214a..0000000 --- a/internal/format/writer.go +++ /dev/null @@ -1,19 +0,0 @@ -package format - -import "io" - -type OutputMode string - -const ( - OutputModeWide OutputMode = "wide" - OutputModeCompact OutputMode = "compact" -) - -type Hints struct { - Props []Prop - OutputMode OutputMode -} - -type Writer interface { - Write(writer io.Writer, hints Hints, data ...any) error -} diff --git a/internal/imports/format/format_import.go b/internal/imports/format/format_import.go index 0e3caa9..1d77c75 100644 --- a/internal/imports/format/format_import.go +++ b/internal/imports/format/format_import.go @@ -1,6 +1,6 @@ package format import ( - _ "forge.cadoles.com/Cadoles/emissary/internal/format/json" - _ "forge.cadoles.com/Cadoles/emissary/internal/format/table" + _ "gitlab.com/wpetit/goweb/cli/format/json" + _ "gitlab.com/wpetit/goweb/cli/format/table" ) diff --git a/internal/server/agent_api.go b/internal/server/agent_api.go index 74f11aa..e6c4abf 100644 --- a/internal/server/agent_api.go +++ b/internal/server/agent_api.go @@ -20,6 +20,8 @@ const ( ErrCodeNotFound api.ErrorCode = "not-found" ErrCodeInvalidSignature api.ErrorCode = "invalid-signature" ErrCodeConflict api.ErrorCode = "conflict" + ErrCodeMultipleResults api.ErrorCode = "multiple-results" + ErrCodeAlreadyClaimed api.ErrorCode = "already-claimed" ) type registerAgentRequest struct { @@ -130,7 +132,8 @@ func (s *Server) registerAgent(w http.ResponseWriter, r *http.Request) { } agent, err = s.agentRepo.Update( - ctx, agents[0].ID, + ctx, + agents[0].ID, datastore.WithAgentUpdateKeySet(keySet), datastore.WithAgentUpdateMetadata(metadata), datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint), @@ -201,6 +204,11 @@ func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) { } func (s *Server) queryAgents(w http.ResponseWriter, r *http.Request) { + user, ok := assertRequestUser(w, r) + if !ok { + return + } + limit, ok := getIntQueryParam(w, r, "limit", 10) if !ok { return @@ -214,6 +222,7 @@ func (s *Server) queryAgents(w http.ResponseWriter, r *http.Request) { options := []datastore.AgentQueryOptionFunc{ datastore.WithAgentQueryLimit(int(limit)), datastore.WithAgentQueryOffset(int(offset)), + datastore.WithAgentQueryTenantID(user.Tenant()), } ids, ok := getIntSliceValues(w, r, "ids", nil) @@ -290,6 +299,10 @@ func (s *Server) deleteAgent(w http.ResponseWriter, r *http.Request) { return } + if ok := s.assertTenantOwns(w, r, agentID); !ok { + return + } + ctx := r.Context() err := s.agentRepo.Delete( @@ -323,6 +336,10 @@ func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) { return } + if ok := s.assertTenantOwns(w, r, agentID); !ok { + return + } + ctx := r.Context() agent, err := s.agentRepo.Get( @@ -350,6 +367,73 @@ func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) { }) } +type claimAgentRequest struct { + Thumbprint string `json:"thumbprint" validate:"required"` +} + +func (s *Server) claimAgent(w http.ResponseWriter, r *http.Request) { + user, ok := assertRequestUser(w, r) + if !ok { + return + } + + ctx := r.Context() + + claimAgentReq := &claimAgentRequest{} + if ok := api.Bind(w, r, claimAgentReq); !ok { + return + } + + results, _, err := s.agentRepo.Query( + ctx, + datastore.WithAgentQueryThumbprints(claimAgentReq.Thumbprint), + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not query agents", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + if len(results) == 0 { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + if len(results) > 1 { + logger.Error(ctx, "multiple results for agent thumbprint", logger.F("agentThumbprint", claimAgentReq.Thumbprint)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeMultipleResults, nil) + + return + } + + agent := results[0] + + if agent.TenantID != nil { + logger.Error(ctx, "agent already claimed", logger.F("agentThumbprint", claimAgentReq.Thumbprint), logger.F("agentID", agent.ID), logger.F("tenantID", agent.TenantID)) + api.ErrorResponse(w, http.StatusConflict, ErrCodeAlreadyClaimed, nil) + + return + } + + agent, err = s.agentRepo.Attach(ctx, user.Tenant(), agent.ID) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not attach agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Agent *datastore.Agent `json:"agent"` + }{ + Agent: agent, + }) +} + func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool) { rawAgentID := chi.URLParam(r, "agentID") diff --git a/internal/server/authorization.go b/internal/server/authorization.go index 643a58a..c2ef4b2 100644 --- a/internal/server/authorization.go +++ b/internal/server/authorization.go @@ -8,6 +8,7 @@ import ( "forge.cadoles.com/Cadoles/emissary/internal/auth" "forge.cadoles.com/Cadoles/emissary/internal/auth/agent" "forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" "github.com/pkg/errors" "gitlab.com/wpetit/goweb/api" "gitlab.com/wpetit/goweb/logger" @@ -132,7 +133,7 @@ func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool) return nil, false } - if user == nil { + if user == nil || user.Tenant() == "" { forbidden(w, r) return nil, false @@ -141,6 +142,30 @@ func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool) return user, true } +func (s *Server) assertTenantOwns(w http.ResponseWriter, r *http.Request, agentID datastore.AgentID) bool { + ctx := r.Context() + + user, ok := assertRequestUser(w, r) + if !ok { + return false + } + + agent, err := s.agentRepo.Get(ctx, agentID) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not get agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + } + + if agent.TenantID != nil && *agent.TenantID == user.Tenant() { + return true + } + + api.ErrorResponse(w, http.StatusForbidden, ErrCodeForbidden, nil) + + return false +} + func forbidden(w http.ResponseWriter, r *http.Request) { logger.Warn(r.Context(), "forbidden", logger.F("path", r.URL.Path)) diff --git a/internal/server/server.go b/internal/server/server.go index 41d0af9..4b138c1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -110,7 +110,10 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e )) r.Route("/agents", func(r chi.Router) { + r.Post("/claim", s.claimAgent) + r.With(assertGlobalReadAccess).Get("/", s.queryAgents) + r.With(assertAgentReadAccess).Get("/{agentID}", s.getAgent) r.With(assertAgentWriteAccess).Put("/{agentID}", s.updateAgent) r.With(assertAgentWriteAccess).Delete("/{agentID}", s.deleteAgent) @@ -205,18 +208,16 @@ func (s *Server) getThirdPartyAuthenticator() (*thirdparty.Authenticator, error) return nil, errors.WithStack(err) } - return thirdparty.NewAuthenticator(getKeySet, getTokenRole, thirdparty.DefaultAcceptableSkew), nil -} - -func (s *Server) createGetTokenRoleFunc() (func(ctx context.Context, token jwt.Token) (string, error), error) { - rawRules := s.conf.Auth.RoleExtractionRules - rules := make([]*vm.Program, 0, len(rawRules)) - - type Env struct { - JWT map[string]any `expr:"jwt"` + getTenantRole, err := s.createGetTokenTenantFunc() + if err != nil { + return nil, errors.WithStack(err) } - strFunc := expr.Function( + return thirdparty.NewAuthenticator(getKeySet, getTokenRole, getTenantRole, thirdparty.DefaultAcceptableSkew), nil +} + +var ruleFuncs = []expr.Option{ + expr.Function( "str", func(params ...any) (any, error) { var builder strings.Builder @@ -230,14 +231,24 @@ func (s *Server) createGetTokenRoleFunc() (func(ctx context.Context, token jwt.T return builder.String(), nil }, new(func(any) string), - ) + ), +} + +func (s *Server) createGetTokenRoleFunc() (func(ctx context.Context, token jwt.Token) (string, error), error) { + rawRules := s.conf.Auth.RoleExtractionRules + rules := make([]*vm.Program, 0, len(rawRules)) + + type Env struct { + JWT map[string]any `expr:"jwt"` + } + + opts := append([]expr.Option{ + expr.Env(Env{}), + expr.AsKind(reflect.String), + }, ruleFuncs...) for _, rr := range rawRules { - r, err := expr.Compile(rr, - expr.Env(Env{}), - expr.AsKind(reflect.String), - strFunc, - ) + r, err := expr.Compile(rr, opts...) if err != nil { return nil, errors.Wrapf(err, "could not compile role extraction rule '%s'", rr) } @@ -276,6 +287,59 @@ func (s *Server) createGetTokenRoleFunc() (func(ctx context.Context, token jwt.T }, nil } +func (s *Server) createGetTokenTenantFunc() (func(ctx context.Context, token jwt.Token) (string, error), error) { + rawRules := s.conf.Auth.TenantExtractionRules + rules := make([]*vm.Program, 0, len(rawRules)) + + type Env struct { + JWT map[string]any `expr:"jwt"` + } + + opts := append([]expr.Option{ + expr.Env(Env{}), + expr.AsKind(reflect.String), + }, ruleFuncs...) + + for _, rr := range rawRules { + r, err := expr.Compile(rr, opts...) + if err != nil { + return nil, errors.Wrapf(err, "could not compile role extraction rule '%s'", rr) + } + + rules = append(rules, r) + } + + return func(ctx context.Context, token jwt.Token) (string, error) { + jwt, err := token.AsMap(ctx) + if err != nil { + return "", errors.WithStack(err) + } + + vm := vm.VM{} + + for _, r := range rules { + result, err := vm.Run(r, Env{ + JWT: jwt, + }) + if err != nil { + return "", errors.WithStack(err) + } + + tenant, ok := result.(string) + if !ok { + logger.Debug(ctx, "ignoring unexpected tenant extraction result", logger.F("result", result)) + continue + } + + if tenant != "" { + return tenant, nil + } + } + + return "", errors.New("could not extract tenant from token") + }, nil +} + func New(funcs ...OptionFunc) *Server { opt := defaultOption() for _, fn := range funcs { diff --git a/migrations/sqlite/0000003_tenant.down.sql b/migrations/sqlite/0000003_tenant.down.sql new file mode 100644 index 0000000..90fef94 --- /dev/null +++ b/migrations/sqlite/0000003_tenant.down.sql @@ -0,0 +1,41 @@ +ALTER TABLE agents RENAME TO _agents; + +CREATE TABLE agents +( + id INTEGER PRIMARY KEY, + thumbprint TEXT UNIQUE, + keyset TEXT, + metadata TEXT, + status INTEGER NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + label TEXT DEFAULT "", + contacted_at datetime +); + +INSERT INTO agents SELECT id, thumbprint, keyset, metadata, status, created_at, updated_at, label, contacted_at FROM _agents; +DROP TABLE _agents; + +--- + +ALTER TABLE specs RENAME TO _specs; + +CREATE TABLE specs +( + id INTEGER PRIMARY KEY, + thumbprint TEXT UNIQUE, + keyset TEXT, + metadata TEXT, + status INTEGER NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + label TEXT DEFAULT "" +); + +INSERT INTO specs SELECT id, agent_id, name, revision, data, created_at, updated_at FROM _specs; +DROP TABLE _specs; + +--- + +DROP TABLE tenants; + diff --git a/migrations/sqlite/0000003_tenant.up.sql b/migrations/sqlite/0000003_tenant.up.sql new file mode 100644 index 0000000..9b0c4c4 --- /dev/null +++ b/migrations/sqlite/0000003_tenant.up.sql @@ -0,0 +1,64 @@ +CREATE TABLE tenants ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL +); + +-- Insert default tenant + +INSERT INTO tenants + ( id, label, created_at, updated_at ) + VALUES ( + '00000000-0000-0000-0000-000000000000', + 'Default', + date('now'), + date('now') + ) +; + +-- Add foreign key to agents + +ALTER TABLE agents RENAME TO _agents; + +CREATE TABLE agents +( + id INTEGER PRIMARY KEY, + thumbprint TEXT UNIQUE, + keyset TEXT, + metadata TEXT, + status INTEGER NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + label TEXT DEFAULT "", + contacted_at datetime, + tenant_id TEXT, + FOREIGN KEY (tenant_id) REFERENCES tenants (id) +); + +INSERT INTO agents SELECT id, thumbprint, keyset, metadata, status, created_at, updated_at, label, contacted_at, 0 FROM _agents; + +DROP TABLE _agents; + +-- Add foreign key to specs + +ALTER TABLE specs RENAME TO _specs; + +CREATE TABLE specs +( + id INTEGER PRIMARY KEY, + agent_id INTEGER, + name TEXT NOT NULL, + revision INTEGER DEFAULT 0, + data TEXT, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + tenant_id TEXT, + FOREIGN KEY (tenant_id) REFERENCES tenants (id), + FOREIGN KEY (agent_id) REFERENCES agents (id) ON DELETE CASCADE, + UNIQUE(agent_id, name) ON CONFLICT REPLACE +); + +INSERT INTO specs SELECT id, agent_id, name, revision, data, created_at, updated_at, 0 FROM _specs; + +DROP TABLE _specs; \ No newline at end of file diff --git a/pkg/client/claim_agent.go b/pkg/client/claim_agent.go new file mode 100644 index 0000000..59c2875 --- /dev/null +++ b/pkg/client/claim_agent.go @@ -0,0 +1,27 @@ +package client + +import ( + "context" + + "github.com/pkg/errors" +) + +func (c *Client) ClaimAgent(ctx context.Context, agentThumbprint string, funcs ...OptionFunc) (*Agent, error) { + response := withResponse[struct { + Agent *Agent `json:"agent"` + }]() + + payload := map[string]any{ + "thumbprint": agentThumbprint, + } + + if err := c.apiPost(ctx, "/api/v1/agents/claim", payload, &response, funcs...); err != nil { + return nil, errors.WithStack(err) + } + + if response.Error != nil { + return nil, errors.WithStack(response.Error) + } + + return response.Data.Agent, nil +}