diff --git a/.gitignore b/.gitignore index faebbb2..be15f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ dist/ /apps /server-key.json /.emissary-token +/.emissary-admin-token +/.emissary-tenant /out .mktools/ /CHANGELOG.md diff --git a/Makefile b/Makefile index 16eb50a..d523831 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,12 @@ OPENWRT_DEVICE ?= 192.168.1.1 watch: deps ## Watching updated files - live reload ( set -o allexport && source .env && set +o allexport && go run -mod=readonly github.com/cortesi/modd/cmd/modd@latest ) +clean: + rm -f .emissary-* + rm -f emissary.sqlite* + rm -f server-key.json + rm -f agent-key.json + .PHONY: test test: test-go ## Executing tests @@ -122,16 +128,25 @@ gitea-release: .mktools tools/gitea-release/bin/gitea-release.sh goreleaser chan GITEA_RELEASE_ATTACHMENTS="$$(find .gitea-release/* -type f)" \ 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" +.emissary-tenant: .emissary-admin-token + $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client tenant create --token-file .emissary-admin-token --tenant-label Dev -f json | jq -r '.[0].id' > .emissary-tenant" + +.emissary-admin-token: + $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role admin --output .emissary-admin-token" + +.emissary-token: .emissary-tenant + $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server auth create-token --role writer --output .emissary-token --tenant $(shell cat .emissary-tenant)" AGENT_ID ?= 1 -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 +claim-agent: .emissary-token + $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client agent claim --agent-thumbprint $(shell go run ./cmd/agent agent show-thumbprint)" + +load-sample-specs: .emissary-token + cat misc/spec-samples/app.emissary.cadoles.com.json | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client 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 | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client 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 | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client 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 | $(MAKE) run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml client 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/README.md b/README.md index abcf9aa..913cff7 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,40 @@ Control plane for "edge" (and OpenWRT-based) devices. > ⚠ Emissary is currently in a very alpha stage ! Expect breaking changes... +## Quickstart + +**Dependencies** +- [Go >= 1.21](https://go.dev/) +- `GNU Make` + +```shell +# Start server and a local agent +make watch + +# In an other terminal + +# Create an admin token +make .emissary-admin-token + +# Create a new tenant +make .emissary-tenant + +# Create a new writer token for this tenant +make .emissary-token + +# Claim the agent for your newly created tenant +make claim-agent + +# Query your agents +./bin/server client agent query + +# Load sample specs for your agent +make load-sample-specs + +## Optional: reset your workspace +make clean +``` + ## Install ### Manually diff --git a/cmd/agent/main.go b/cmd/agent/main.go index c741325..6c1c699 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -5,7 +5,7 @@ import ( "forge.cadoles.com/Cadoles/emissary/internal/command" "forge.cadoles.com/Cadoles/emissary/internal/command/agent" - "forge.cadoles.com/Cadoles/emissary/internal/command/api" + "forge.cadoles.com/Cadoles/emissary/internal/command/client" _ "forge.cadoles.com/Cadoles/emissary/internal/imports/format" _ "forge.cadoles.com/Cadoles/emissary/internal/imports/spec" @@ -20,5 +20,5 @@ var ( ) func main() { - command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), api.Root()) + command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, agent.Root(), client.Root()) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 17cd8df..a3f0265 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,7 +4,7 @@ import ( "time" "forge.cadoles.com/Cadoles/emissary/internal/command" - "forge.cadoles.com/Cadoles/emissary/internal/command/api" + "forge.cadoles.com/Cadoles/emissary/internal/command/client" "forge.cadoles.com/Cadoles/emissary/internal/command/server" _ "forge.cadoles.com/Cadoles/emissary/internal/imports/format" @@ -21,5 +21,5 @@ var ( ) func main() { - command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), api.Root()) + command.Main(BuildDate, ProjectVersion, GitRef, DefaultConfigPath, server.Root(), client.Root()) } diff --git a/doc/README.md b/doc/README.md index 7719b81..7b054fb 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,6 +1,7 @@ # Documentation -- (FR) - [Introduction](./fr/introduction.md) +- (FR) - [Vue d'ensemble](./others/fr/overview.md) +- (FR) - [Authentification et autorisation](./others/fr/auth.md) ## Tutorials @@ -9,15 +10,13 @@ - (FR) - [Déployer une configuration UCI personnalisée sur un agent](./tutorials/fr/deploy-uci-configuration.md) - (FR) - [Démarrer un agent avec Docker](./tutorials/fr/docker-agent.md) -## References - ### Specifications -- [Schéma `app.emissary.cadoles.com`](../internal/agent/controller/app/spec/schema.json) -- [Schéma `proxy.emissary.cadoles.com`](../internal/spec/proxy/schema.json) -- [Schéma `mdns.emissary.cadoles.com`](../internal/agent/controller/mdns/spec/schema.json) -- [Schéma `uci.emissary.cadoles.com`](../internal/spec/uci/schema.json) -- [Schéma `sysupgrade.openwrt.emissary.cadoles.com`](../internal/agent/controller/openwrt/spec/sysupgrade/schema.json) +- [Schema `app.emissary.cadoles.com`](../internal/agent/controller/app/spec/schema.json) +- [Schema `proxy.emissary.cadoles.com`](../internal/spec/proxy/schema.json) +- [Schema `mdns.emissary.cadoles.com`](../internal/agent/controller/mdns/spec/schema.json) +- [Schema `uci.emissary.cadoles.com`](../internal/spec/uci/schema.json) +- [Schema `sysupgrade.openwrt.emissary.cadoles.com`](../internal/agent/controller/openwrt/spec/sysupgrade/schema.json) ### Configuration diff --git a/doc/others/fr/auth.md b/doc/others/fr/auth.md new file mode 100644 index 0000000..b022fe5 --- /dev/null +++ b/doc/others/fr/auth.md @@ -0,0 +1,27 @@ +# Authentification et autorisation + +## Authentification + +Emissary utilise des [**JSON Web Token**](https://fr.wikipedia.org/wiki/JSON_Web_Token) (JWT) afin d'authentifier les appels à son API REST. + +L'implémentation est compatible avec tout serveur d'authentification exposant une URL proposant un [**JSON Web Key Set**](https://www.ory.sh/docs/hydra/jwks#the-role-of-well-knownjwksjson). + +La plupart des serveurs OpenID Connect exposent un point d'entrée du type [`/.well-known/jwks.json`](https://www.ory.sh/docs/hydra/jwks#the-role-of-well-knownjwksjson) remplissant ce rôle. + +Emissary est également en capacité à fonctionner en mode autonome en générant des JWTs signés par une clé privée locale. + +## Ségrégation des ressources + +Emissary suit une stratégie ["multitenant"](https://fr.wikipedia.org/wiki/Multitenant) de séparer les ressources par organisation. + +Un utilisateur est obligatoirement associé à un `tenant`` et ne peut opérer que sur les ressources associées à celui ci. + +## Autorisation + +Au sein d'un `tenant`, un utilisateur peut avoir un des rôles suivants: + +- `writer` - Autorisé à visualiser et modifier les ressources; +- `reader` - Autorisé à visualiser les ressources. + +Un rôle spécial `admin` permet la création et la suppression de `tenants`. + diff --git a/doc/fr/introduction.md b/doc/others/fr/overview.md similarity index 98% rename from doc/fr/introduction.md rename to doc/others/fr/overview.md index 587dd12..0afaee5 100644 --- a/doc/fr/introduction.md +++ b/doc/others/fr/overview.md @@ -1,4 +1,4 @@ -# Introduction +# Vue d'ensemble "Emissary" est un programme entrant dans la catégorie des outils de gestion et déploiement de configuration. diff --git a/doc/fr/resources/overview.plantuml b/doc/others/fr/resources/overview.plantuml similarity index 100% rename from doc/fr/resources/overview.plantuml rename to doc/others/fr/resources/overview.plantuml diff --git a/doc/fr/resources/overview.svg b/doc/others/fr/resources/overview.svg similarity index 100% rename from doc/fr/resources/overview.svg rename to doc/others/fr/resources/overview.svg diff --git a/doc/tutorials/fr/deploy-uci-configuration.md b/doc/tutorials/fr/deploy-uci-configuration.md index a29a250..c07d096 100644 --- a/doc/tutorials/fr/deploy-uci-configuration.md +++ b/doc/tutorials/fr/deploy-uci-configuration.md @@ -80,13 +80,13 @@ Via la spécification [`uci.emissary.cadoles.com`](../../../internal/spec/uci/sc AGENT_THUMBPRINT="" # Récupérer l'identifiant de l'agent - AGENT_ID=$(emissary api agent query -f json | jq -r --arg thumbprint "$AGENT_THUMBPRINT" '.[] | select(.thumbprint == $thumbprint) | .id') + AGENT_ID=$(emissary client agent query -f json | jq -r --arg thumbprint "$AGENT_THUMBPRINT" '.[] | select(.thumbprint == $thumbprint) | .id') ``` 2. Assigner la spécification à l'agent UCI: ```bash - cat my-uci-spec.json | emissary api agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com + cat my-uci-spec.json | emissary client agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com ``` **Bravo, vous avez déployé des spécifications UCI sur votre agent !** @@ -112,7 +112,7 @@ En intervenant directement sur notre spécification, il est possible de modifier 2. Mettre à jour la configuration de l'agent: ```bash - cat my-uci-spec.json | emissary api agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com + cat my-uci-spec.json | emissary client agent spec update -a ${AGENT_ID} --no-patch --spec-data - --spec-name uci.emissary.cadoles.com ``` 3. Sur l'agent, après quelques secondes (par défaut, la fréquence de mise à jour est de 1 fois par minute) l'agent devrait avoir son `hostname` mis à jour: diff --git a/doc/tutorials/fr/first-steps.md b/doc/tutorials/fr/first-steps.md index 4eb7de2..ff6bc18 100644 --- a/doc/tutorials/fr/first-steps.md +++ b/doc/tutorials/fr/first-steps.md @@ -80,15 +80,31 @@ 5. Créer un jeton d'administration: ```shell - sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role writer --subject $(whoami) + sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role admin -o "$HOME/.config/emissary/admin-token" ``` > **Note** Le jeton sera stocké dans le répertoire `$HOME/.config/emissary`. -6. Vérifier l'authentification sur l'API: +6. Créer un nouveau `tenant`: ```shell - emissary api agent query + sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml client tenant create --tenant-label "My Tenant" -o wide --token-file "$HOME/.config/emissary/admin-token" + ``` + + Noter la valeur de l'UUID (de la forme `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) affiché dans la colonne `ID`. Il sera identifié comme `$EMISSARY_TENANT` dans les étapes suivantes. + +7. Créer un jeton d'authentification pour ce nouveau tenant: + + ```shell + sudo emissary --workdir /usr/share/emissary --config /etc/emissary/server.yml server auth create-token --role writer --tenant $EMISSARY_TENANT + ``` + + > **Note** Le jeton sera stocké dans le fichier `$HOME/.config/emissary/auth-token`. Il sera le jeton utilisé par défaut par le CLI Emissary. + +8. Vérifier l'authentification sur l'API: + + ```shell + emissary client agent query ``` Une réponse équivalente à la suivante devrait s'afficher: @@ -128,10 +144,18 @@ Thu May 25 18:48:51 2023 daemon.info emissary[2202]: 2023-05-25 18:48:51.680 [INFO] <./internal/agent/controller/openwrt/sysupgrade_controller.go:36> (*SysUpgradeController).Reconcile could not find sysupgrade spec, doing nothing {"controller": "sysupgrade-controller"} ``` -3. Sur le serveur, vérifier que l'agent a pu s'enregistrer: +2. Récupérer le `thumbprint` de votre agent: + + ``` + emissary agent show-thumbprint + ``` + + Noter la valeur de la chaîne de caractères affichée. Elle sera identifiée comme `$AGENT_THUMBPRINT` dans les étapes suivantes. + +3. Sur le serveur, "réclamer" votre agent: ```shell - emissary api agent query + emissary client agent claim --agent-thumbprint $AGENT_THUMBPRINT ``` Un message de ce type devrait s'afficher: @@ -144,12 +168,12 @@ +----+-------+-----------------------------------+--------+-----------------------------------+-----------------------------------+ ``` - Noter l'identifiant associé à l'agent. + Noter la valeur de l'identifiant affiché dans la colonne `ID`. Il sera identifié comme `$AGENT_ID` dans les étapes suivantes. 4. Mettre à jour le statut de l'agent afin qu'il soit en capacité à récupérer ses spécifications: ``` - emissary api agent update --agent-id --status 1 + emissary client agent update --agent-id $AGENT_ID --status 1 ``` **Bravo, vous avez appairé votre premier agent et son serveur Emissary !** 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..5d082c6 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() + contactedAt := time.Now().UTC() 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/user.go b/internal/auth/thirdparty/user.go deleted file mode 100644 index db26da6..0000000 --- a/internal/auth/thirdparty/user.go +++ /dev/null @@ -1,32 +0,0 @@ -package thirdparty - -import "forge.cadoles.com/Cadoles/emissary/internal/auth" - -type Role string - -const ( - RoleWriter Role = "writer" - RoleReader Role = "reader" -) - -func isValidRole(r string) bool { - rr := Role(r) - - return rr == RoleWriter || rr == RoleReader -} - -type User struct { - subject string - role Role -} - -// Subject implements auth.User -func (u *User) Subject() string { - return u.subject -} - -func (u *User) Role() Role { - return u.role -} - -var _ auth.User = &User{} diff --git a/internal/auth/thirdparty/authenticator.go b/internal/auth/user/authenticator.go similarity index 50% rename from internal/auth/thirdparty/authenticator.go rename to internal/auth/user/authenticator.go index 95af1e5..0801ba4 100644 --- a/internal/auth/thirdparty/authenticator.go +++ b/internal/auth/user/authenticator.go @@ -1,4 +1,4 @@ -package thirdparty +package user import ( "context" @@ -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/user/jwt.go similarity index 76% rename from internal/auth/thirdparty/jwt.go rename to internal/auth/user/jwt.go index 1e76465..411a05d 100644 --- a/internal/auth/thirdparty/jwt.go +++ b/internal/auth/user/jwt.go @@ -1,9 +1,10 @@ -package thirdparty +package user 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/user/user.go b/internal/auth/user/user.go new file mode 100644 index 0000000..b2e9d49 --- /dev/null +++ b/internal/auth/user/user.go @@ -0,0 +1,42 @@ +package user + +import ( + "forge.cadoles.com/Cadoles/emissary/internal/auth" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" +) + +type Role string + +const ( + RoleWriter Role = "writer" + RoleReader Role = "reader" + RoleAdmin Role = "admin" +) + +func isValidRole(r string) bool { + rr := Role(r) + + return rr == RoleWriter || rr == RoleReader || rr == RoleAdmin +} + +type User struct { + subject string + tenantID datastore.TenantID + role Role +} + +// Subject implements auth.User +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 +} + +var _ auth.User = &User{} diff --git a/internal/command/agent/run.go b/internal/command/agent/run.go index 7862b09..0c2f782 100644 --- a/internal/command/agent/run.go +++ b/internal/command/agent/run.go @@ -104,6 +104,10 @@ func RunCommand() *cli.Command { return errors.WithStack(err) } + logger.SetLevel(logger.LevelInfo) + logger.Info(ctx.Context, "agent thumbprint", logger.F("thumbprint", thumbprint)) + logger.SetLevel(logger.Level(conf.Logger.Level)) + collectors := createShellCollectors(&conf.Agent) collectors = append(collectors, buildinfo.NewCollector()) diff --git a/internal/command/api/root.go b/internal/command/api/root.go deleted file mode 100644 index 261a6f7..0000000 --- a/internal/command/api/root.go +++ /dev/null @@ -1,16 +0,0 @@ -package api - -import ( - "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent" - "github.com/urfave/cli/v2" -) - -func Root() *cli.Command { - return &cli.Command{ - Name: "api", - Usage: "API related commands", - Subcommands: []*cli.Command{ - agent.Root(), - }, - } -} diff --git a/internal/command/client/agent/claim.go b/internal/command/client/agent/claim.go new file mode 100644 index 0000000..7ca7d01 --- /dev/null +++ b/internal/command/client/agent/claim.go @@ -0,0 +1,51 @@ +package agent + +import ( + "os" + + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/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/client/agent/count.go similarity index 89% rename from internal/command/api/agent/count.go rename to internal/command/client/agent/count.go index 42834a4..b11c4cf 100644 --- a/internal/command/api/agent/count.go +++ b/internal/command/client/agent/count.go @@ -3,12 +3,12 @@ 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/internal/format" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" "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/client/agent/delete.go similarity index 89% rename from internal/command/api/agent/delete.go rename to internal/command/client/agent/delete.go index 3cb230f..b8e5dce 100644 --- a/internal/command/api/agent/delete.go +++ b/internal/command/client/agent/delete.go @@ -3,14 +3,14 @@ package agent import ( "os" - 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" + agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/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/flag/flag.go b/internal/command/client/agent/flag/flag.go similarity index 98% rename from internal/command/api/agent/flag/flag.go rename to internal/command/client/agent/flag/flag.go index 6ac3b9c..ad48034 100644 --- a/internal/command/api/agent/flag/flag.go +++ b/internal/command/client/agent/flag/flag.go @@ -3,7 +3,7 @@ package flag import ( "errors" - clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" "forge.cadoles.com/Cadoles/emissary/internal/datastore" "github.com/urfave/cli/v2" ) diff --git a/internal/command/api/agent/get.go b/internal/command/client/agent/get.go similarity index 88% rename from internal/command/api/agent/get.go rename to internal/command/client/agent/get.go index bba75df..8af5c99 100644 --- a/internal/command/api/agent/get.go +++ b/internal/command/client/agent/get.go @@ -3,13 +3,13 @@ package agent import ( "os" - 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" + agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" "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/util.go b/internal/command/client/agent/hints.go similarity index 53% rename from internal/command/api/agent/util.go rename to internal/command/client/agent/hints.go index 80b9ad5..acb75f2 100644 --- a/internal/command/api/agent/util.go +++ b/internal/command/client/agent/hints.go @@ -1,6 +1,9 @@ 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{ @@ -10,8 +13,8 @@ func agentHints(outputMode format.OutputMode) format.Hints { 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/agent/query.go b/internal/command/client/agent/query.go similarity index 94% rename from internal/command/api/agent/query.go rename to internal/command/client/agent/query.go index d763525..dfff411 100644 --- a/internal/command/api/agent/query.go +++ b/internal/command/client/agent/query.go @@ -3,13 +3,13 @@ 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/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/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/client/agent/root.go similarity index 77% rename from internal/command/api/agent/root.go rename to internal/command/client/agent/root.go index d238bd6..6682c66 100644 --- a/internal/command/api/agent/root.go +++ b/internal/command/client/agent/root.go @@ -1,7 +1,7 @@ package agent import ( - "forge.cadoles.com/Cadoles/emissary/internal/command/api/agent/spec" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/spec" "github.com/urfave/cli/v2" ) @@ -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/client/agent/spec/delete.go similarity index 90% rename from internal/command/api/agent/spec/delete.go rename to internal/command/client/agent/spec/delete.go index e9762e4..45d6cac 100644 --- a/internal/command/api/agent/spec/delete.go +++ b/internal/command/client/agent/spec/delete.go @@ -3,14 +3,14 @@ package spec import ( "os" - 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" + agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" "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/client/agent/spec/get.go similarity index 88% rename from internal/command/api/agent/spec/get.go rename to internal/command/client/agent/spec/get.go index 460f239..ea671cb 100644 --- a/internal/command/api/agent/spec/get.go +++ b/internal/command/client/agent/spec/get.go @@ -3,13 +3,13 @@ package spec import ( "os" - 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" + agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" "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/root.go b/internal/command/client/agent/spec/root.go similarity index 100% rename from internal/command/api/agent/spec/root.go rename to internal/command/client/agent/spec/root.go diff --git a/internal/command/api/agent/spec/update.go b/internal/command/client/agent/spec/update.go similarity index 96% rename from internal/command/api/agent/spec/update.go rename to internal/command/client/agent/spec/update.go index fb359c4..23da653 100644 --- a/internal/command/api/agent/spec/update.go +++ b/internal/command/client/agent/spec/update.go @@ -4,15 +4,15 @@ import ( "encoding/json" "os" - 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" + agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" "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/client/agent/update.go similarity index 91% rename from internal/command/api/agent/update.go rename to internal/command/client/agent/update.go index 1152819..5df7080 100644 --- a/internal/command/api/agent/update.go +++ b/internal/command/client/agent/update.go @@ -3,13 +3,13 @@ package agent import ( "os" - 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" + agentFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent/flag" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" "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/apierr/wrap.go b/internal/command/client/apierr/wrap.go similarity index 100% rename from internal/command/api/apierr/wrap.go rename to internal/command/client/apierr/wrap.go diff --git a/internal/command/api/flag/flag.go b/internal/command/client/flag/flag.go similarity index 95% rename from internal/command/api/flag/flag.go rename to internal/command/client/flag/flag.go index 270aa97..c463933 100644 --- a/internal/command/api/flag/flag.go +++ b/internal/command/client/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/api/flag/util.go b/internal/command/client/flag/util.go similarity index 100% rename from internal/command/api/flag/util.go rename to internal/command/client/flag/util.go diff --git a/internal/command/client/root.go b/internal/command/client/root.go new file mode 100644 index 0000000..32c29d6 --- /dev/null +++ b/internal/command/client/root.go @@ -0,0 +1,18 @@ +package client + +import ( + "forge.cadoles.com/Cadoles/emissary/internal/command/client/agent" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant" + "github.com/urfave/cli/v2" +) + +func Root() *cli.Command { + return &cli.Command{ + Name: "client", + Usage: "API client related commands", + Subcommands: []*cli.Command{ + agent.Root(), + tenant.Root(), + }, + } +} diff --git a/internal/command/client/tenant/create.go b/internal/command/client/tenant/create.go new file mode 100644 index 0000000..1321537 --- /dev/null +++ b/internal/command/client/tenant/create.go @@ -0,0 +1,51 @@ +package tenant + +import ( + "os" + + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" + "forge.cadoles.com/Cadoles/emissary/pkg/client" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" + "gitlab.com/wpetit/goweb/cli/format" +) + +func CreateCommand() *cli.Command { + return &cli.Command{ + Name: "create", + Usage: "Create tenant", + Flags: clientFlag.ComposeFlags( + &cli.StringFlag{ + Name: "tenant-label", + Usage: "Set `TENANT_LABEL` to targeted tenant", + Value: "", + }, + ), + Action: func(ctx *cli.Context) error { + baseFlags := clientFlag.GetBaseFlags(ctx) + + token, err := clientFlag.GetToken(baseFlags) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + tenantLabel := ctx.String("tenant-label") + + client := client.New(baseFlags.ServerURL, client.WithToken(token)) + + agent, err := client.CreateTenant(ctx.Context, tenantLabel) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + hints := tenantHints(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/client/tenant/delete.go b/internal/command/client/tenant/delete.go new file mode 100644 index 0000000..c99554c --- /dev/null +++ b/internal/command/client/tenant/delete.go @@ -0,0 +1,56 @@ +package tenant + +import ( + "os" + + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" + tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "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 { + return &cli.Command{ + Name: "delete", + Usage: "Delete tenant", + Flags: tenantFlag.WithTenantFlags(), + Action: func(ctx *cli.Context) error { + baseFlags := clientFlag.GetBaseFlags(ctx) + + token, err := clientFlag.GetToken(baseFlags) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + tenantID, err := tenantFlag.AssertTenantID(ctx) + if err != nil { + return errors.WithStack(err) + } + + client := client.New(baseFlags.ServerURL, client.WithToken(token)) + + tenantID, err = client.DeleteTenant(ctx.Context, tenantID) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + hints := format.Hints{ + OutputMode: baseFlags.OutputMode, + } + + if err := format.Write(baseFlags.Format, os.Stdout, hints, struct { + ID datastore.TenantID `json:"id"` + }{ + ID: tenantID, + }); err != nil { + return errors.WithStack(err) + } + + return nil + }, + } +} diff --git a/internal/command/client/tenant/flag/flag.go b/internal/command/client/tenant/flag/flag.go new file mode 100644 index 0000000..4652969 --- /dev/null +++ b/internal/command/client/tenant/flag/flag.go @@ -0,0 +1,37 @@ +package flag + +import ( + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "github.com/urfave/cli/v2" +) + +func WithTenantFlags(flags ...cli.Flag) []cli.Flag { + baseFlags := clientFlag.ComposeFlags( + &cli.StringFlag{ + Name: "tenant-id", + Usage: "use `TENANT_ID` as targeted tenant", + Value: "", + }, + ) + + flags = append(flags, baseFlags...) + + return flags +} + +func AssertTenantID(ctx *cli.Context) (datastore.TenantID, error) { + rawTenantID := ctx.String("tenant-id") + + if rawTenantID == "" { + return "", errors.New("flag 'tenant-id' is required") + } + + tenantID, err := datastore.ParseTenantID(rawTenantID) + if err != nil { + return "", errors.WithStack(err) + } + + return tenantID, nil +} diff --git a/internal/command/client/tenant/get.go b/internal/command/client/tenant/get.go new file mode 100644 index 0000000..4c925e1 --- /dev/null +++ b/internal/command/client/tenant/get.go @@ -0,0 +1,49 @@ +package tenant + +import ( + "os" + + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" + tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag" + "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 { + return &cli.Command{ + Name: "get", + Usage: "Get tenant", + Flags: tenantFlag.WithTenantFlags(), + Action: func(ctx *cli.Context) error { + baseFlags := clientFlag.GetBaseFlags(ctx) + + token, err := clientFlag.GetToken(baseFlags) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + tenantID, err := tenantFlag.AssertTenantID(ctx) + if err != nil { + return errors.WithStack(err) + } + + client := client.New(baseFlags.ServerURL, client.WithToken(token)) + + agent, err := client.GetTenant(ctx.Context, tenantID) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + hints := tenantHints(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/client/tenant/hints.go b/internal/command/client/tenant/hints.go new file mode 100644 index 0000000..ca76dfc --- /dev/null +++ b/internal/command/client/tenant/hints.go @@ -0,0 +1,18 @@ +package tenant + +import ( + "gitlab.com/wpetit/goweb/cli/format" + "gitlab.com/wpetit/goweb/cli/format/table" +) + +func tenantHints(outputMode format.OutputMode) format.Hints { + return format.Hints{ + OutputMode: outputMode, + Props: []format.Prop{ + format.NewProp("ID", "ID", table.WithCompactModeMaxColumnWidth(8)), + format.NewProp("Label", "Label"), + format.NewProp("CreatedAt", "CreatedAt", table.WithCompactModeMaxColumnWidth(20)), + format.NewProp("UpdatedAt", "UpdatedAt", table.WithCompactModeMaxColumnWidth(20)), + }, + } +} diff --git a/internal/command/client/tenant/query.go b/internal/command/client/tenant/query.go new file mode 100644 index 0000000..3473c4f --- /dev/null +++ b/internal/command/client/tenant/query.go @@ -0,0 +1,63 @@ +package tenant + +import ( + "os" + + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "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 { + return &cli.Command{ + Name: "query", + Usage: "Query tenants", + Flags: clientFlag.ComposeFlags( + &cli.Int64SliceFlag{ + Name: "ids", + Usage: "use `IDS` as query filter", + }, + ), + Action: func(ctx *cli.Context) error { + baseFlags := clientFlag.GetBaseFlags(ctx) + + token, err := clientFlag.GetToken(baseFlags) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + options := make([]client.QueryTenantsOptionFunc, 0) + + rawIDs := ctx.StringSlice("ids") + if rawIDs != nil { + tenantIDs := func(ids []string) []datastore.TenantID { + tenantIDs := make([]datastore.TenantID, len(ids)) + for i, id := range ids { + tenantIDs[i] = datastore.TenantID(id) + } + return tenantIDs + }(rawIDs) + options = append(options, client.WithQueryTenantsID(tenantIDs...)) + } + + client := client.New(baseFlags.ServerURL, client.WithToken(token)) + + tenants, _, err := client.QueryTenants(ctx.Context, options...) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + hints := tenantHints(baseFlags.OutputMode) + + if err := format.Write(baseFlags.Format, os.Stdout, hints, clientFlag.AsAnySlice(tenants)...); err != nil { + return errors.WithStack(err) + } + + return nil + }, + } +} diff --git a/internal/command/client/tenant/root.go b/internal/command/client/tenant/root.go new file mode 100644 index 0000000..7be676f --- /dev/null +++ b/internal/command/client/tenant/root.go @@ -0,0 +1,19 @@ +package tenant + +import ( + "github.com/urfave/cli/v2" +) + +func Root() *cli.Command { + return &cli.Command{ + Name: "tenant", + Usage: "Tenants related commands", + Subcommands: []*cli.Command{ + CreateCommand(), + GetCommand(), + UpdateCommand(), + DeleteCommand(), + QueryCommand(), + }, + } +} diff --git a/internal/command/client/tenant/update.go b/internal/command/client/tenant/update.go new file mode 100644 index 0000000..355a8dc --- /dev/null +++ b/internal/command/client/tenant/update.go @@ -0,0 +1,62 @@ +package tenant + +import ( + "os" + + "forge.cadoles.com/Cadoles/emissary/internal/command/client/apierr" + clientFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/flag" + tenantFlag "forge.cadoles.com/Cadoles/emissary/internal/command/client/tenant/flag" + "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 { + return &cli.Command{ + Name: "update", + Usage: "Update tenant", + Flags: tenantFlag.WithTenantFlags( + &cli.StringFlag{ + Name: "tenant-label", + Usage: "Set `TENANT_LABEL` to targeted tenant", + Value: "", + }, + ), + Action: func(ctx *cli.Context) error { + baseFlags := clientFlag.GetBaseFlags(ctx) + + token, err := clientFlag.GetToken(baseFlags) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + tenantID, err := tenantFlag.AssertTenantID(ctx) + if err != nil { + return errors.WithStack(err) + } + + options := make([]client.UpdateTenantOptionFunc, 0) + + label := ctx.String("tenant-label") + if label != "" { + options = append(options, client.WithTenantLabel(label)) + } + + client := client.New(baseFlags.ServerURL, client.WithToken(token)) + + agent, err := client.UpdateTenant(ctx.Context, tenantID, options...) + if err != nil { + return errors.WithStack(apierr.Wrap(err)) + } + + hints := tenantHints(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/server/auth/create_token.go b/internal/command/server/auth/create_token.go index 8c8554d..b1c0f7b 100644 --- a/internal/command/server/auth/create_token.go +++ b/internal/command/server/auth/create_token.go @@ -5,11 +5,12 @@ import ( "os" "path/filepath" - "forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" - "forge.cadoles.com/Cadoles/emissary/internal/command/api/flag" + "forge.cadoles.com/Cadoles/emissary/internal/auth/user" + "forge.cadoles.com/Cadoles/emissary/internal/command/client/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/google/uuid" "github.com/pkg/errors" "github.com/urfave/cli/v2" ) @@ -21,13 +22,18 @@ func CreateTokenCommand() *cli.Command { Flags: []cli.Flag{ &cli.StringFlag{ Name: "role", - Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []thirdparty.Role{thirdparty.RoleReader, thirdparty.RoleWriter}), - Value: string(thirdparty.RoleReader), + Usage: fmt.Sprintf("associate `ROLE` to the token (available: %v)", []user.Role{user.RoleReader, user.RoleWriter, user.RoleAdmin}), + Value: string(user.RoleReader), }, &cli.StringFlag{ Name: "subject", Usage: "associate `SUBJECT` to the token", - Value: fmt.Sprintf("user-%s", shortuuid.New()), + Value: fmt.Sprintf("user-%s", uuid.New().String()), + }, + &cli.StringFlag{ + Name: "tenant", + Usage: "associate `TENANT` to the token", + Value: "00000000-0000-0000-0000-000000000000", }, &cli.StringFlag{ Name: "output", @@ -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 := user.GenerateToken(ctx.Context, key, datastore.TenantID(tenant), subject, user.Role(role)) if err != nil { return errors.WithStack(err) } diff --git a/internal/config/server.go b/internal/config/server.go index 8b192fb..55618bb 100644 --- a/internal/config/server.go +++ b/internal/config/server.go @@ -3,7 +3,7 @@ package config import ( "fmt" - "forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" + "forge.cadoles.com/Cadoles/emissary/internal/auth/user" ) type ServerConfig struct { @@ -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 { @@ -35,7 +36,10 @@ func NewDefaultAuthConfig() AuthConfig { }, Remote: nil, RoleExtractionRules: []string{ - fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", thirdparty.DefaultRoleKey, thirdparty.DefaultRoleKey), + fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", user.DefaultRoleKey, user.DefaultRoleKey), + }, + TenantExtractionRules: []string{ + fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", user.DefaultTenantKey, user.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..cc8ba50 100644 --- a/internal/datastore/sqlite/agent_repository.go +++ b/internal/datastore/sqlite/agent_repository.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "fmt" - "strings" "time" "forge.cadoles.com/Cadoles/emissary/internal/datastore" @@ -16,8 +15,116 @@ import ( ) type AgentRepository struct { - db *sql.DB - sqliteBusyRetryMaxAttempts int + repository +} + +// 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 WHERE id = $3 + RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id" + ` + + row = tx.QueryRowContext( + ctx, query, + tenantID, + now, + agentID, + ) + + 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 (r *AgentRepository) Detach(ctx context.Context, agentID datastore.AgentID) (*datastore.Agent, error) { + var agent datastore.Agent + + err := r.withTxRetry(ctx, func(tx *sql.Tx) error { + now := time.Now().UTC() + + query := ` + UPDATE agents SET tenant_id = null, updated_at = $1 WHERE id = $2 + RETURNING "id", "thumbprint", "keyset", "metadata", "status", "created_at", "updated_at", "tenant_id" + ` + + row := tx.QueryRowContext( + ctx, query, + now, + agentID, + ) + + 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 } // DeleteSpec implements datastore.AgentRepository. @@ -123,8 +230,8 @@ func (r *AgentRepository) UpdateSpec(ctx context.Context, agentID datastore.Agen now := time.Now().UTC() query := ` - INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at) - VALUES($1, $2, $3, $4, $5, $5) + INSERT INTO specs (agent_id, name, revision, data, created_at, updated_at, tenant_id) + VALUES($1, $2, $3, $4, $5, $5, ( SELECT tenant_id FROM agents WHERE id = $1 )) ON CONFLICT (agent_id, name) DO UPDATE SET data = $4, updated_at = $5, revision = specs.revision + 1 WHERE revision = $3 @@ -170,7 +277,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 +300,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 +354,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 +407,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 +422,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 +477,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 +488,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 +590,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 +601,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 } @@ -536,109 +650,8 @@ func (r *AgentRepository) agentExists(ctx context.Context, tx *sql.Tx, agentID d return true, nil } -func (r *AgentRepository) withTxRetry(ctx context.Context, fn func(*sql.Tx) error) error { - attempts := 0 - max := r.sqliteBusyRetryMaxAttempts - - ctx = logger.With(ctx, logger.F("max", max)) - - var err error - for { - ctx = logger.With(ctx) - - if attempts >= max { - logger.Debug(ctx, "transaction retrying failed", logger.F("attempts", attempts)) - - return errors.Wrapf(err, "transaction failed after %d attempts", max) - } - - err = r.withTx(ctx, fn) - if err != nil { - if !strings.Contains(err.Error(), "(5) (SQLITE_BUSY)") { - return errors.WithStack(err) - } - - err = errors.WithStack(err) - - logger.Warn(ctx, "database is busy", logger.E(err)) - - wait := time.Duration(8<<(attempts+1)) * time.Millisecond - - logger.Debug( - ctx, "database is busy, waiting before retrying transaction", - logger.F("wait", wait.String()), - logger.F("attempts", attempts), - ) - - timer := time.NewTimer(wait) - select { - case <-timer.C: - attempts++ - continue - - case <-ctx.Done(): - if err := ctx.Err(); err != nil { - return errors.WithStack(err) - } - - return nil - } - } - - return nil - } -} - -func (r *AgentRepository) withTx(ctx context.Context, fn func(*sql.Tx) error) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return errors.WithStack(err) - } - - defer func() { - if err := tx.Rollback(); err != nil { - if errors.Is(err, sql.ErrTxDone) { - return - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not rollback transaction", logger.CapturedE(err)) - } - }() - - if err := fn(tx); err != nil { - return errors.WithStack(err) - } - - if err := tx.Commit(); err != nil { - return errors.WithStack(err) - } - - return nil -} - func NewAgentRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *AgentRepository { - return &AgentRepository{db, sqliteBusyRetryMaxAttempts} + return &AgentRepository{repository{db, sqliteBusyRetryMaxAttempts}} } 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/repository.go b/internal/datastore/sqlite/repository.go new file mode 100644 index 0000000..84ae573 --- /dev/null +++ b/internal/datastore/sqlite/repository.go @@ -0,0 +1,97 @@ +package sqlite + +import ( + "context" + "database/sql" + "strings" + "time" + + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +type repository struct { + db *sql.DB + sqliteBusyRetryMaxAttempts int +} + +func (r *repository) withTxRetry(ctx context.Context, fn func(*sql.Tx) error) error { + attempts := 0 + max := r.sqliteBusyRetryMaxAttempts + + ctx = logger.With(ctx, logger.F("max", max)) + + var err error + for { + ctx = logger.With(ctx) + + if attempts >= max { + logger.Debug(ctx, "transaction retrying failed", logger.F("attempts", attempts)) + + return errors.Wrapf(err, "transaction failed after %d attempts", max) + } + + err = r.withTx(ctx, fn) + if err != nil { + if !strings.Contains(err.Error(), "(5) (SQLITE_BUSY)") { + return errors.WithStack(err) + } + + err = errors.WithStack(err) + + logger.Warn(ctx, "database is busy", logger.E(err)) + + wait := time.Duration(8<<(attempts+1)) * time.Millisecond + + logger.Debug( + ctx, "database is busy, waiting before retrying transaction", + logger.F("wait", wait.String()), + logger.F("attempts", attempts), + ) + + timer := time.NewTimer(wait) + select { + case <-timer.C: + attempts++ + continue + + case <-ctx.Done(): + if err := ctx.Err(); err != nil { + return errors.WithStack(err) + } + + return nil + } + } + + return nil + } +} + +func (r *repository) withTx(ctx context.Context, fn func(*sql.Tx) error) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return errors.WithStack(err) + } + + defer func() { + if err := tx.Rollback(); err != nil { + if errors.Is(err, sql.ErrTxDone) { + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not rollback transaction", logger.CapturedE(err)) + } + }() + + if err := fn(tx); err != nil { + return errors.WithStack(err) + } + + if err := tx.Commit(); err != nil { + return errors.WithStack(err) + } + + return nil +} 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/sqlite/tenant_repository.go b/internal/datastore/sqlite/tenant_repository.go new file mode 100644 index 0000000..bf820fd --- /dev/null +++ b/internal/datastore/sqlite/tenant_repository.go @@ -0,0 +1,284 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" +) + +type TenantRepository struct { + repository +} + +// Query implements datastore.TenantRepository. +func (r *TenantRepository) Query(ctx context.Context, opts ...datastore.TenantQueryOptionFunc) ([]*datastore.Tenant, int, error) { + options := &datastore.TenantQueryOptions{} + for _, fn := range opts { + fn(options) + } + + tenants := make([]*datastore.Tenant, 0) + count := 0 + + err := r.withTxRetry(ctx, func(tx *sql.Tx) error { + query := `SELECT id, label, created_at, updated_at FROM tenants` + + limit := 10 + if options.Limit != nil { + limit = *options.Limit + } + + offset := 0 + if options.Offset != nil { + offset = *options.Offset + } + + filters := "" + paramIndex := 3 + args := []any{offset, limit} + + if options.IDs != nil && len(options.IDs) > 0 { + filter, newArgs, newParamIndex := inFilter("id", paramIndex, options.IDs) + filters += filter + paramIndex = newParamIndex + args = append(args, newArgs...) + } + + if filters != "" { + filters = ` WHERE ` + filters + } + + query += filters + ` LIMIT $2 OFFSET $1` + + logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args)) + + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return errors.WithStack(err) + } + + defer func() { + if err := rows.Close(); err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not close rows", logger.CapturedE(err)) + } + }() + + for rows.Next() { + tenant := &datastore.Tenant{} + + if err := rows.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil { + return errors.WithStack(err) + } + + tenants = append(tenants, tenant) + } + + if err := rows.Err(); err != nil { + return errors.WithStack(err) + } + + row := tx.QueryRowContext(ctx, `SELECT count(id) FROM tenants `+filters, args...) + if err := row.Scan(&count); err != nil { + return errors.WithStack(err) + } + + return nil + }) + if err != nil { + return nil, 0, errors.WithStack(err) + } + + return tenants, count, nil +} + +// Create implements datastore.TenantRepository. +func (r *TenantRepository) Create(ctx context.Context, label string) (*datastore.Tenant, error) { + var tenant datastore.Tenant + + err := r.withTxRetry(ctx, func(tx *sql.Tx) error { + now := time.Now().UTC() + + query := ` + INSERT INTO tenants (id, label, created_at, updated_at) + VALUES($1, $2, $3, $3) + RETURNING "id", "label", "created_at", "updated_at" + ` + + tenantID := datastore.NewTenantID() + + row := tx.QueryRowContext( + ctx, query, + tenantID, label, now, + ) + + if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil { + return errors.WithStack(err) + } + + return nil + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return &tenant, nil +} + +// Delete implements datastore.TenantRepository. +func (r *TenantRepository) Delete(ctx context.Context, id datastore.TenantID) error { + err := r.withTxRetry(ctx, func(tx *sql.Tx) error { + if exists, err := r.tenantExists(ctx, tx, id); !exists { + return errors.WithStack(err) + } + + query := `DELETE FROM tenants WHERE id = $1` + _, err := tx.ExecContext(ctx, query, id) + if err != nil { + return errors.WithStack(err) + } + + query = `DELETE FROM agents WHERE tenant_id = $1` + _, err = tx.ExecContext(ctx, query, id) + if err != nil { + return errors.WithStack(err) + } + + query = `DELETE FROM specs WHERE tenant_id = $1` + _, err = tx.ExecContext(ctx, query, id) + if err != nil { + return errors.WithStack(err) + } + + return nil + }) + if err != nil { + return errors.WithStack(err) + } + + return nil +} + +// Get implements datastore.TenantRepository. +func (r *TenantRepository) Get(ctx context.Context, id datastore.TenantID) (*datastore.Tenant, error) { + var tenant datastore.Tenant + + err := r.withTxRetry(ctx, func(tx *sql.Tx) error { + query := ` + SELECT "id", "label", "created_at", "updated_at" + FROM tenants + WHERE id = $1 + ` + + row := tx.QueryRowContext(ctx, query, id) + + if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return errors.WithStack(datastore.ErrNotFound) + } + + return errors.WithStack(err) + } + + return nil + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return &tenant, nil +} + +// Update implements datastore.TenantRepository. +func (r *TenantRepository) Update(ctx context.Context, id datastore.TenantID, updates ...datastore.TenantUpdateOptionFunc) (*datastore.Tenant, error) { + options := &datastore.TenantUpdateOptions{} + for _, fn := range updates { + fn(options) + } + + var tenant datastore.Tenant + + err := r.withTxRetry(ctx, func(tx *sql.Tx) error { + if exists, err := r.tenantExists(ctx, tx, id); !exists { + return errors.WithStack(err) + } + + query := ` + UPDATE tenants SET updated_at = $1 + ` + + args := []any{id} + index := 2 + + if options.Label != nil { + query += fmt.Sprintf(`, label = $%d`, index) + args = append(args, *options.Label) + index++ + } + + updated := options.Label != nil + if updated { + now := time.Now().UTC() + query += fmt.Sprintf(`, updated_at = $%d`, index) + args = append(args, now) + index++ + } + + query += ` + WHERE id = $1 + RETURNING "id", "label", "created_at", "updated_at" + ` + + logger.Debug(ctx, "executing query", logger.F("query", query), logger.F("args", args)) + + row := tx.QueryRowContext(ctx, query, args...) + + if err := row.Scan(&tenant.ID, &tenant.Label, &tenant.CreatedAt, &tenant.UpdatedAt); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return errors.WithStack(datastore.ErrNotFound) + } + + return errors.WithStack(err) + } + + return nil + }) + if err != nil { + return nil, errors.WithStack(err) + } + + return &tenant, nil +} + +func (r *TenantRepository) tenantExists(ctx context.Context, tx *sql.Tx, tenantID datastore.TenantID) (bool, error) { + row := tx.QueryRowContext(ctx, `SELECT count(id) FROM tenants WHERE id = $1`, tenantID) + + var count int + + if err := row.Scan(&count); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, errors.WithStack(datastore.ErrNotFound) + } + + return false, errors.WithStack(err) + } + + if count == 0 { + return false, errors.WithStack(datastore.ErrNotFound) + } + + return true, nil +} + +func NewTenantRepository(db *sql.DB, sqliteBusyRetryMaxAttempts int) *TenantRepository { + return &TenantRepository{ + repository: repository{db, sqliteBusyRetryMaxAttempts}, + } +} + +var _ datastore.TenantRepository = &TenantRepository{} diff --git a/internal/datastore/sqlite/tenant_repository_test.go b/internal/datastore/sqlite/tenant_repository_test.go new file mode 100644 index 0000000..ce4a9f9 --- /dev/null +++ b/internal/datastore/sqlite/tenant_repository_test.go @@ -0,0 +1,46 @@ +package sqlite + +import ( + "database/sql" + "fmt" + "os" + "testing" + "time" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore/testsuite" + "forge.cadoles.com/Cadoles/emissary/internal/migrate" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/logger" + + _ "modernc.org/sqlite" +) + +func TestSQLiteTeantRepository(t *testing.T) { + logger.SetLevel(logger.LevelDebug) + + file := "testdata/tenant_repository_test.sqlite" + + if err := os.Remove(file); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("%+v", errors.WithStack(err)) + } + + dsn := fmt.Sprintf("%s?_pragma=foreign_keys(1)&_pragma=busy_timeout=%d", file, (60 * time.Second).Milliseconds()) + + migr, err := migrate.New("../../../migrations", "sqlite", "sqlite://"+dsn) + if err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + if err := migr.Up(); err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + db, err := sql.Open("sqlite", dsn) + if err != nil { + t.Fatalf("%+v", errors.WithStack(err)) + } + + repo := NewTenantRepository(db, 5) + + testsuite.TestTenantRepository(t, repo) +} 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/datastore/tenant_repository.go b/internal/datastore/tenant_repository.go new file mode 100644 index 0000000..fd5a346 --- /dev/null +++ b/internal/datastore/tenant_repository.go @@ -0,0 +1,50 @@ +package datastore + +import "context" + +type TenantRepository interface { + Create(ctx context.Context, label string) (*Tenant, error) + Get(ctx context.Context, id TenantID) (*Tenant, error) + Update(ctx context.Context, id TenantID, updates ...TenantUpdateOptionFunc) (*Tenant, error) + Delete(ctx context.Context, id TenantID) error + + Query(ctx context.Context, opts ...TenantQueryOptionFunc) ([]*Tenant, int, error) +} + +type TenantUpdateOptionFunc func(*TenantUpdateOptions) + +type TenantUpdateOptions struct { + Label *string +} + +func WithTenantUpdateLabel(label string) TenantUpdateOptionFunc { + return func(opts *TenantUpdateOptions) { + opts.Label = &label + } +} + +type TenantQueryOptionFunc func(*TenantQueryOptions) + +type TenantQueryOptions struct { + Limit *int + Offset *int + IDs []TenantID +} + +func WithTenantQueryLimit(limit int) TenantQueryOptionFunc { + return func(opts *TenantQueryOptions) { + opts.Limit = &limit + } +} + +func WithTenantQueryOffset(offset int) TenantQueryOptionFunc { + return func(opts *TenantQueryOptions) { + opts.Offset = &offset + } +} + +func WithTenantQueryID(ids ...TenantID) TenantQueryOptionFunc { + return func(opts *TenantQueryOptions) { + opts.IDs = ids + } +} diff --git a/internal/datastore/testsuite/tenant_repository.go b/internal/datastore/testsuite/tenant_repository.go new file mode 100644 index 0000000..deddfe0 --- /dev/null +++ b/internal/datastore/testsuite/tenant_repository.go @@ -0,0 +1,14 @@ +package testsuite + +import ( + "testing" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" +) + +func TestTenantRepository(t *testing.T, repo datastore.TenantRepository) { + t.Run("Cases", func(t *testing.T) { + t.Parallel() + runTenantRepositoryTests(t, repo) + }) +} diff --git a/internal/datastore/testsuite/tenant_repository_cases.go b/internal/datastore/testsuite/tenant_repository_cases.go new file mode 100644 index 0000000..0ebacf2 --- /dev/null +++ b/internal/datastore/testsuite/tenant_repository_cases.go @@ -0,0 +1,109 @@ +package testsuite + +import ( + "context" + "testing" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" +) + +type tenantRepositoryTestCase struct { + Name string + Skip bool + Run func(ctx context.Context, repo datastore.TenantRepository) error +} + +var tenantRepositoryTestCases = []tenantRepositoryTestCase{ + { + Name: "Create a new tenant", + Run: func(ctx context.Context, repo datastore.TenantRepository) error { + label := "Foo" + tenant, err := repo.Create(ctx, "Foo") + if err != nil { + return errors.WithStack(err) + } + + if tenant.CreatedAt.IsZero() { + return errors.Errorf("tenant.CreatedAt should not be zero time") + } + + if tenant.UpdatedAt.IsZero() { + return errors.Errorf("tenant.UpdatedAt should not be zero time") + } + + if e, g := label, tenant.Label; e != g { + return errors.Errorf("tenant.Label: expected '%v', got '%v'", e, g) + } + + if tenant.ID == "" { + return errors.Errorf("tenant.ID should not be empty") + } + + if _, err := datastore.ParseTenantID(string(tenant.ID)); err != nil { + return errors.Wrapf(err, "tenant.ID should be valid") + } + + return nil + }, + }, + { + Name: "Try to update an unexistant tenant", + Run: func(ctx context.Context, repo datastore.TenantRepository) error { + unexistantTenantID := datastore.TenantID("00000000-0000-0000-0000-000000000000") + tenant, err := repo.Update(ctx, unexistantTenantID) + if err == nil { + return errors.New("error should not be nil") + } + + if !errors.Is(err, datastore.ErrNotFound) { + return errors.Errorf("error should be datastore.ErrNotFound, got '%+v'", err) + } + + if tenant != nil { + return errors.New("tenant should be nil") + } + + return nil + }, + }, + { + Name: "Try to delete spec of an unexistant agent", + Run: func(ctx context.Context, repo datastore.TenantRepository) error { + unexistantTenantID := datastore.TenantID("00000000-0000-0000-0000-000000000000") + + err := repo.Delete(ctx, unexistantTenantID) + if err == nil { + return errors.New("error should not be nil") + } + + if !errors.Is(err, datastore.ErrNotFound) { + return errors.Errorf("error should be datastore.ErrNotFound, got '%+v'", err) + } + + return nil + }, + }, +} + +func runTenantRepositoryTests(t *testing.T, repo datastore.TenantRepository) { + for _, tc := range tenantRepositoryTestCases { + func(tc tenantRepositoryTestCase) { + t.Run(tc.Name, func(t *testing.T) { + t.Parallel() + + if tc.Skip { + t.SkipNow() + + return + } + + ctx := context.Background() + + if err := tc.Run(ctx, repo); err != nil { + t.Errorf("%+v", errors.WithStack(err)) + } + }) + }(tc) + } +} 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 deleted file mode 100644 index 74f11aa..0000000 --- a/internal/server/agent_api.go +++ /dev/null @@ -1,420 +0,0 @@ -package server - -import ( - "encoding/json" - "net/http" - "strconv" - "strings" - - "forge.cadoles.com/Cadoles/emissary/internal/agent/metadata" - "forge.cadoles.com/Cadoles/emissary/internal/datastore" - "forge.cadoles.com/Cadoles/emissary/internal/jwk" - "github.com/go-chi/chi/v5" - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/api" - "gitlab.com/wpetit/goweb/logger" -) - -const ( - ErrCodeUnknownError api.ErrorCode = "unknown-error" - ErrCodeNotFound api.ErrorCode = "not-found" - ErrCodeInvalidSignature api.ErrorCode = "invalid-signature" - ErrCodeConflict api.ErrorCode = "conflict" -) - -type registerAgentRequest struct { - KeySet json.RawMessage `json:"keySet" validate:"required"` - Metadata []metadata.Tuple `json:"metadata" validate:"required"` - Thumbprint string `json:"thumbprint" validate:"required"` - Signature string `json:"signature" validate:"required"` -} - -func (s *Server) registerAgent(w http.ResponseWriter, r *http.Request) { - registerAgentReq := ®isterAgentRequest{} - if ok := api.Bind(w, r, registerAgentReq); !ok { - return - } - - ctx := r.Context() - - keySet, err := jwk.Parse(registerAgentReq.KeySet) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not parse key set", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - ctx = logger.With(ctx, logger.F("agentThumbprint", registerAgentReq.Thumbprint)) - - // Validate that the existing signature validates the request - - validSignature, err := jwk.Verify(keySet, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not validate signature", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - if !validSignature { - logger.Warn(ctx, "conflicting signature", logger.F("signature", registerAgentReq.Signature)) - api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil) - - return - } - - metadata := metadata.FromSorted(registerAgentReq.Metadata) - - agent, err := s.agentRepo.Create( - ctx, - registerAgentReq.Thumbprint, - keySet, - metadata, - ) - if err != nil { - if !errors.Is(err, datastore.ErrAlreadyExist) { - err = errors.WithStack(err) - logger.Error(ctx, "could not create agent", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - agents, _, err := s.agentRepo.Query( - ctx, - datastore.WithAgentQueryThumbprints(registerAgentReq.Thumbprint), - datastore.WithAgentQueryLimit(1), - ) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not retrieve agents", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - if len(agents) == 0 { - err = errors.WithStack(err) - logger.Error(ctx, "could not retrieve matching agent", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeNotFound, nil) - - return - } - - agentID := agents[0].ID - - agent, err = s.agentRepo.Get(ctx, agentID) - if err != nil { - err = errors.WithStack(err) - logger.Error( - ctx, "could not retrieve agent", - logger.CapturedE(err), logger.F("agentID", agentID), - ) - - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - validSignature, err = jwk.Verify(agent.KeySet.Set, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not validate signature using previous keyset", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil) - - return - } - - agent, err = s.agentRepo.Update( - ctx, agents[0].ID, - datastore.WithAgentUpdateKeySet(keySet), - datastore.WithAgentUpdateMetadata(metadata), - datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint), - ) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not update agent", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - } - - api.DataResponse(w, http.StatusCreated, struct { - Agent *datastore.Agent `json:"agent"` - }{ - Agent: agent, - }) -} - -type updateAgentRequest struct { - Status *datastore.AgentStatus `json:"status" validate:"omitempty,oneof=0 1 2 3"` - Label *string `json:"label" validate:"omitempty"` -} - -func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) { - agentID, ok := getAgentID(w, r) - if !ok { - return - } - - ctx := r.Context() - - updateAgentReq := &updateAgentRequest{} - if ok := api.Bind(w, r, updateAgentReq); !ok { - return - } - - options := make([]datastore.AgentUpdateOptionFunc, 0) - - if updateAgentReq.Status != nil { - options = append(options, datastore.WithAgentUpdateStatus(*updateAgentReq.Status)) - } - - if updateAgentReq.Label != nil { - options = append(options, datastore.WithAgentUpdateLabel(*updateAgentReq.Label)) - } - - agent, err := s.agentRepo.Update( - ctx, - datastore.AgentID(agentID), - options..., - ) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not update 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 (s *Server) queryAgents(w http.ResponseWriter, r *http.Request) { - limit, ok := getIntQueryParam(w, r, "limit", 10) - if !ok { - return - } - - offset, ok := getIntQueryParam(w, r, "offset", 0) - if !ok { - return - } - - options := []datastore.AgentQueryOptionFunc{ - datastore.WithAgentQueryLimit(int(limit)), - datastore.WithAgentQueryOffset(int(offset)), - } - - ids, ok := getIntSliceValues(w, r, "ids", nil) - if !ok { - return - } - - if ids != nil { - agentIDs := func(ids []int64) []datastore.AgentID { - agentIDs := make([]datastore.AgentID, 0, len(ids)) - for _, id := range ids { - agentIDs = append(agentIDs, datastore.AgentID(id)) - } - - return agentIDs - }(ids) - - options = append(options, datastore.WithAgentQueryID(agentIDs...)) - } - - thumbprints, ok := getStringSliceValues(w, r, "thumbprints", nil) - if !ok { - return - } - - if thumbprints != nil { - options = append(options, datastore.WithAgentQueryThumbprints(thumbprints...)) - } - - statuses, ok := getIntSliceValues(w, r, "statuses", nil) - if !ok { - return - } - - if statuses != nil { - agentStatuses := func(statuses []int64) []datastore.AgentStatus { - agentStatuses := make([]datastore.AgentStatus, 0, len(statuses)) - for _, status := range statuses { - agentStatuses = append(agentStatuses, datastore.AgentStatus(status)) - } - - return agentStatuses - }(statuses) - - options = append(options, datastore.WithAgentQueryStatus(agentStatuses...)) - } - - ctx := r.Context() - - agents, total, err := s.agentRepo.Query( - ctx, - options..., - ) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not list agents", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - api.DataResponse(w, http.StatusOK, struct { - Agents []*datastore.Agent `json:"agents"` - Total int `json:"total"` - }{ - Agents: agents, - Total: total, - }) -} - -func (s *Server) deleteAgent(w http.ResponseWriter, r *http.Request) { - agentID, ok := getAgentID(w, r) - if !ok { - return - } - - ctx := r.Context() - - err := s.agentRepo.Delete( - ctx, - datastore.AgentID(agentID), - ) - if err != nil { - if errors.Is(err, datastore.ErrNotFound) { - api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) - - return - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not delete agent", logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - api.DataResponse(w, http.StatusOK, struct { - AgentID datastore.AgentID `json:"agentId"` - }{ - AgentID: datastore.AgentID(agentID), - }) -} - -func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) { - agentID, ok := getAgentID(w, r) - if !ok { - return - } - - ctx := r.Context() - - agent, err := s.agentRepo.Get( - ctx, - datastore.AgentID(agentID), - ) - if err != nil { - if errors.Is(err, datastore.ErrNotFound) { - api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) - - return - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not get 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") - - agentID, err := strconv.ParseInt(rawAgentID, 10, 64) - if err != nil { - logger.Error(r.Context(), "could not parse agent id", logger.CapturedE(errors.WithStack(err))) - api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) - - return 0, false - } - - return datastore.AgentID(agentID), true -} - -func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defaultValue int64) (int64, bool) { - rawValue := r.URL.Query().Get(param) - if rawValue != "" { - value, err := strconv.ParseInt(rawValue, 10, 64) - if err != nil { - err = errors.WithStack(err) - logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) - - return 0, false - } - - return value, true - } - - return defaultValue, true -} - -func getStringSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []string) ([]string, bool) { - rawValue := r.URL.Query().Get(param) - if rawValue != "" { - values := strings.Split(rawValue, ",") - - return values, true - } - - return defaultValue, true -} - -func getIntSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []int64) ([]int64, bool) { - rawValue := r.URL.Query().Get(param) - - if rawValue != "" { - rawValues := strings.Split(rawValue, ",") - values := make([]int64, 0, len(rawValues)) - - for _, rv := range rawValues { - value, err := strconv.ParseInt(rv, 10, 64) - if err != nil { - err = errors.WithStack(err) - logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.CapturedE(err)) - api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) - - return nil, false - } - - values = append(values, value) - } - - return values, true - } - - return defaultValue, true -} diff --git a/internal/server/api/authorization.go b/internal/server/api/authorization.go new file mode 100644 index 0000000..db08329 --- /dev/null +++ b/internal/server/api/authorization.go @@ -0,0 +1,251 @@ +package api + +import ( + "context" + "fmt" + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/auth" + "forge.cadoles.com/Cadoles/emissary/internal/auth/agent" + "forge.cadoles.com/Cadoles/emissary/internal/auth/user" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +var ErrCodeForbidden api.ErrorCode = "forbidden" + +func assertQueryAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfRoles(user.RoleReader, user.RoleWriter, user.RoleAdmin), + nil, + ) +} + +func assertUserWithWriteAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfRoles(user.RoleWriter, user.RoleAdmin), + nil, + ) +} + +func assertAgentOrUserWithWriteAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfRoles(user.RoleWriter, user.RoleAdmin), + assertMatchingAgent(), + ) +} + +func assertAgentOrUserWithReadAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfRoles(user.RoleReader, user.RoleWriter, user.RoleAdmin), + assertMatchingAgent(), + ) +} + +func assertAdminAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfRoles(user.RoleAdmin), + nil, + ) +} + +func assertAdminOrTenantReadAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfUser( + assertOneOfRoles(user.RoleAdmin), + assertAllOfUser( + assertOneOfRoles(user.RoleReader, user.RoleWriter), + assertSameTenant(), + ), + ), + nil, + ) +} + +func assertAdminOrTenantWriteAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfUser( + assertOneOfRoles(user.RoleAdmin), + assertAllOfUser( + assertOneOfRoles(user.RoleWriter), + assertSameTenant(), + ), + ), + nil, + ) +} + +func assertAuthz(h http.Handler, assertUser assertUser, assertAgent assertAgent) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + reqUser, ok := assertRequestUser(w, r) + if !ok { + return + } + + switch u := reqUser.(type) { + case *user.User: + if assertUser != nil { + if ok := assertUser(w, r, u); ok { + h.ServeHTTP(w, r) + + return + } + } + + case *agent.User: + if assertAgent != nil { + if ok := assertAgent(w, r, u); ok { + h.ServeHTTP(w, r) + + return + } + } + default: + logUnexpectedUserType(r.Context(), reqUser) + } + + forbidden(w, r) + } + + return http.HandlerFunc(fn) + +} + +type assertUser func(w http.ResponseWriter, r *http.Request, u *user.User) bool +type assertAgent func(w http.ResponseWriter, r *http.Request, u *agent.User) bool + +func assertAllOfUser(funcs ...assertUser) assertUser { + return func(w http.ResponseWriter, r *http.Request, u *user.User) bool { + for _, fn := range funcs { + if ok := fn(w, r, u); !ok { + return false + } + } + + return true + } +} + +func assertOneOfUser(funcs ...assertUser) assertUser { + return func(w http.ResponseWriter, r *http.Request, u *user.User) bool { + for _, fn := range funcs { + if ok := fn(w, r, u); ok { + return true + } + } + + return false + } +} + +func assertSameTenant() assertUser { + return func(w http.ResponseWriter, r *http.Request, u *user.User) bool { + tenantID, ok := getTenantID(w, r) + if !ok { + return false + } + + if u.Tenant() == tenantID { + return true + } + + return false + } +} + +func assertOneOfRoles(roles ...user.Role) assertUser { + return func(w http.ResponseWriter, r *http.Request, u *user.User) bool { + role := u.Role() + for _, rr := range roles { + if rr == role { + return true + } + } + + return false + } +} + +func assertMatchingAgent() assertAgent { + return func(w http.ResponseWriter, r *http.Request, u *agent.User) bool { + agentID, ok := getAgentID(w, r) + if !ok { + return false + } + + agent := u.Agent() + if agent != nil && agent.ID == agentID { + return true + } + + return false + } +} + +func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool) { + ctx := r.Context() + user, err := auth.CtxUser(ctx) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not retrieve user", logger.CapturedE(err)) + + forbidden(w, r) + + return nil, false + } + + if user == nil || user.Tenant() == "" { + forbidden(w, r) + + return nil, false + } + + return user, true +} + +func (m *Mount) 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 := m.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)) + + api.ErrorResponse(w, http.StatusForbidden, ErrCodeForbidden, nil) +} + +func logUnexpectedUserType(ctx context.Context, user auth.User) { + logger.Warn( + ctx, "unexpected user type", + logger.F("subject", user.Subject()), + logger.F("type", fmt.Sprintf("%T", user)), + ) +} diff --git a/internal/server/api/claim_agent.go b/internal/server/api/claim_agent.go new file mode 100644 index 0000000..bb02ad2 --- /dev/null +++ b/internal/server/api/claim_agent.go @@ -0,0 +1,77 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type claimAgentRequest struct { + Thumbprint string `json:"thumbprint" validate:"required"` +} + +func (m *Mount) 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 := m.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 = m.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, + }) +} diff --git a/internal/server/api/create_tenant.go b/internal/server/api/create_tenant.go new file mode 100644 index 0000000..cde6bb3 --- /dev/null +++ b/internal/server/api/create_tenant.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type CreateTenantRequest struct { + Label string `json:"label" validate:"required"` +} + +func (m *Mount) createTenant(w http.ResponseWriter, r *http.Request) { + createTenantReq := &CreateTenantRequest{} + if ok := api.Bind(w, r, createTenantReq); !ok { + return + } + + ctx := r.Context() + + tenant, err := m.tenantRepo.Create(ctx, createTenantReq.Label) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not create tenant", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Tenant *datastore.Tenant `json:"tenant"` + }{ + Tenant: tenant, + }) +} diff --git a/internal/server/api/delete_agent.go b/internal/server/api/delete_agent.go new file mode 100644 index 0000000..76decf5 --- /dev/null +++ b/internal/server/api/delete_agent.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) deleteAgent(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + if ok := m.assertTenantOwns(w, r, agentID); !ok { + return + } + + ctx := r.Context() + + err := m.agentRepo.Delete( + ctx, + datastore.AgentID(agentID), + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not delete agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + AgentID datastore.AgentID `json:"agentId"` + }{ + AgentID: datastore.AgentID(agentID), + }) +} diff --git a/internal/server/api/delete_tenant.go b/internal/server/api/delete_tenant.go new file mode 100644 index 0000000..6e41cd4 --- /dev/null +++ b/internal/server/api/delete_tenant.go @@ -0,0 +1,43 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) deleteTenant(w http.ResponseWriter, r *http.Request) { + tenantID, ok := getTenantID(w, r) + if !ok { + return + } + + ctx := r.Context() + + err := m.tenantRepo.Delete( + ctx, + tenantID, + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not delete tenant", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + TenantID datastore.TenantID `json:"tenantId"` + }{ + TenantID: tenantID, + }) +} diff --git a/internal/server/api/get_agent.go b/internal/server/api/get_agent.go new file mode 100644 index 0000000..c1af46a --- /dev/null +++ b/internal/server/api/get_agent.go @@ -0,0 +1,47 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) getAgent(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + if ok := m.assertTenantOwns(w, r, agentID); !ok { + return + } + + ctx := r.Context() + + agent, err := m.agentRepo.Get( + ctx, + datastore.AgentID(agentID), + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not get 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, + }) +} diff --git a/internal/server/api/get_specs.go b/internal/server/api/get_specs.go new file mode 100644 index 0000000..cc81625 --- /dev/null +++ b/internal/server/api/get_specs.go @@ -0,0 +1,85 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) getAgentSpecs(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + ctx := r.Context() + + specs, err := m.agentRepo.GetSpecs(ctx, agentID) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not list specs", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Specs []*datastore.Spec `json:"specs"` + }{ + Specs: specs, + }) +} + +type deleteSpecRequest struct { + Name string `json:"name"` +} + +func (m *Mount) deleteSpec(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + deleteSpecReq := &deleteSpecRequest{} + if ok := api.Bind(w, r, deleteSpecReq); !ok { + return + } + + ctx := r.Context() + + err := m.agentRepo.DeleteSpec( + ctx, + agentID, + deleteSpecReq.Name, + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not delete spec", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Name string `json:"name"` + }{ + Name: deleteSpecReq.Name, + }) +} diff --git a/internal/server/api/get_tenant.go b/internal/server/api/get_tenant.go new file mode 100644 index 0000000..62a8210 --- /dev/null +++ b/internal/server/api/get_tenant.go @@ -0,0 +1,40 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) getTenant(w http.ResponseWriter, r *http.Request) { + tenantID, ok := getTenantID(w, r) + if !ok { + return + } + + ctx := r.Context() + + tenant, err := m.tenantRepo.Get(ctx, tenantID) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not get tenant", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Tenant *datastore.Tenant `json:"tenant"` + }{ + Tenant: tenant, + }) +} diff --git a/internal/server/api/helper.go b/internal/server/api/helper.go new file mode 100644 index 0000000..7376e55 --- /dev/null +++ b/internal/server/api/helper.go @@ -0,0 +1,123 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/go-chi/chi/v5" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +const ( + ErrCodeUnknownError api.ErrorCode = "unknown-error" + ErrCodeNotFound api.ErrorCode = "not-found" + ErrCodeInvalidSignature api.ErrorCode = "invalid-signature" + ErrCodeConflict api.ErrorCode = "conflict" + ErrCodeMultipleResults api.ErrorCode = "multiple-results" + ErrCodeAlreadyClaimed api.ErrorCode = "already-claimed" +) + +func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool) { + rawAgentID := chi.URLParam(r, "agentID") + + agentID, err := strconv.ParseInt(rawAgentID, 10, 64) + if err != nil { + logger.Error(r.Context(), "could not parse agent id", logger.CapturedE(errors.WithStack(err))) + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) + + return 0, false + } + + return datastore.AgentID(agentID), true +} + +func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) { + rawSpecID := chi.URLParam(r, "specID") + + specID, err := strconv.ParseInt(rawSpecID, 10, 64) + if err != nil { + err = errors.WithStack(err) + logger.Error(r.Context(), "could not parse spec id", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) + + return 0, false + } + + return datastore.SpecID(specID), true +} + +func getTenantID(w http.ResponseWriter, r *http.Request) (datastore.TenantID, bool) { + rawTenantID := chi.URLParam(r, "tenantID") + + tenantID, err := datastore.ParseTenantID(rawTenantID) + if err != nil { + err = errors.WithStack(err) + logger.Error(r.Context(), "could not parse tenant id", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) + + return "", false + } + + return tenantID, true +} + +func getIntQueryParam(w http.ResponseWriter, r *http.Request, param string, defaultValue int64) (int64, bool) { + rawValue := r.URL.Query().Get(param) + if rawValue != "" { + value, err := strconv.ParseInt(rawValue, 10, 64) + if err != nil { + err = errors.WithStack(err) + logger.Error(r.Context(), "could not parse int param", logger.F("param", param), logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) + + return 0, false + } + + return value, true + } + + return defaultValue, true +} + +func getStringSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []string) ([]string, bool) { + rawValue := r.URL.Query().Get(param) + if rawValue != "" { + values := strings.Split(rawValue, ",") + + return values, true + } + + return defaultValue, true +} + +func getIntSliceValues(w http.ResponseWriter, r *http.Request, param string, defaultValue []int64) ([]int64, bool) { + rawValue := r.URL.Query().Get(param) + + if rawValue != "" { + rawValues := strings.Split(rawValue, ",") + values := make([]int64, 0, len(rawValues)) + + for _, rv := range rawValues { + value, err := strconv.ParseInt(rv, 10, 64) + if err != nil { + err = errors.WithStack(err) + logger.Error(r.Context(), "could not parse int slice param", logger.F("param", param), logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) + + return nil, false + } + + values = append(values, value) + } + + return values, true + } + + return defaultValue, true +} diff --git a/internal/server/api/mount.go b/internal/server/api/mount.go new file mode 100644 index 0000000..0bfc4eb --- /dev/null +++ b/internal/server/api/mount.go @@ -0,0 +1,56 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/auth" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/go-chi/chi/v5" + "gitlab.com/wpetit/goweb/api" +) + +type Mount struct { + agentRepo datastore.AgentRepository + tenantRepo datastore.TenantRepository + authenticators []auth.Authenticator +} + +func (m *Mount) Mount(r chi.Router) { + r.NotFound(m.notFound) + + r.Post("/register", m.registerAgent) + + r.Group(func(r chi.Router) { + r.Use(auth.Middleware(m.authenticators...)) + + r.Route("/agents", func(r chi.Router) { + r.With(assertUserWithWriteAccess).Post("/claim", m.claimAgent) + + r.With(assertQueryAccess).Get("/", m.queryAgents) + + r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}", m.getAgent) + r.With(assertAgentOrUserWithWriteAccess).Put("/{agentID}", m.updateAgent) + r.With(assertUserWithWriteAccess).Delete("/{agentID}", m.deleteAgent) + + r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}/specs", m.getAgentSpecs) + r.With(assertUserWithWriteAccess).Post("/{agentID}/specs", m.updateSpec) + r.With(assertUserWithWriteAccess).Delete("/{agentID}/specs", m.deleteSpec) + }) + + r.Route("/tenants", func(r chi.Router) { + r.With(assertQueryAccess).Get("/", m.queryTenants) + r.With(assertAdminAccess).Post("/", m.createTenant) + r.With(assertAdminOrTenantReadAccess).Get("/{tenantID}", m.getTenant) + r.With(assertAdminOrTenantWriteAccess).Put("/{tenantID}", m.updateTenant) + r.With(assertAdminAccess).Delete("/{tenantID}", m.deleteTenant) + }) + }) +} + +func (m *Mount) notFound(w http.ResponseWriter, r *http.Request) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) +} + +func NewMount(agentRepo datastore.AgentRepository, tenantRepo datastore.TenantRepository, authenticators ...auth.Authenticator) *Mount { + return &Mount{agentRepo, tenantRepo, authenticators} +} diff --git a/internal/server/api/query_agents.go b/internal/server/api/query_agents.go new file mode 100644 index 0000000..24e57de --- /dev/null +++ b/internal/server/api/query_agents.go @@ -0,0 +1,112 @@ +package api + +import ( + "net/http" + + userAuth "forge.cadoles.com/Cadoles/emissary/internal/auth/user" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) queryAgents(w http.ResponseWriter, r *http.Request) { + baseUser, ok := assertRequestUser(w, r) + if !ok { + return + } + + ctx := r.Context() + + user, ok := baseUser.(*userAuth.User) + if !ok { + logger.Error(ctx, "unexpected user type", logger.F("user", baseUser)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + limit, ok := getIntQueryParam(w, r, "limit", 10) + if !ok { + return + } + + offset, ok := getIntQueryParam(w, r, "offset", 0) + if !ok { + return + } + + options := []datastore.AgentQueryOptionFunc{ + datastore.WithAgentQueryLimit(int(limit)), + datastore.WithAgentQueryOffset(int(offset)), + } + + if user.Role() != userAuth.RoleAdmin { + options = append(options, datastore.WithAgentQueryTenantID(user.Tenant())) + } + + ids, ok := getIntSliceValues(w, r, "ids", nil) + if !ok { + return + } + + if ids != nil { + agentIDs := func(ids []int64) []datastore.AgentID { + agentIDs := make([]datastore.AgentID, 0, len(ids)) + for _, id := range ids { + agentIDs = append(agentIDs, datastore.AgentID(id)) + } + + return agentIDs + }(ids) + + options = append(options, datastore.WithAgentQueryID(agentIDs...)) + } + + thumbprints, ok := getStringSliceValues(w, r, "thumbprints", nil) + if !ok { + return + } + + if thumbprints != nil { + options = append(options, datastore.WithAgentQueryThumbprints(thumbprints...)) + } + + statuses, ok := getIntSliceValues(w, r, "statuses", nil) + if !ok { + return + } + + if statuses != nil { + agentStatuses := func(statuses []int64) []datastore.AgentStatus { + agentStatuses := make([]datastore.AgentStatus, 0, len(statuses)) + for _, status := range statuses { + agentStatuses = append(agentStatuses, datastore.AgentStatus(status)) + } + + return agentStatuses + }(statuses) + + options = append(options, datastore.WithAgentQueryStatus(agentStatuses...)) + } + + agents, total, err := m.agentRepo.Query( + ctx, + options..., + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not list agents", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Agents []*datastore.Agent `json:"agents"` + Total int `json:"total"` + }{ + Agents: agents, + Total: total, + }) +} diff --git a/internal/server/api/query_tenants.go b/internal/server/api/query_tenants.go new file mode 100644 index 0000000..576f01d --- /dev/null +++ b/internal/server/api/query_tenants.go @@ -0,0 +1,82 @@ +package api + +import ( + "net/http" + + userAuth "forge.cadoles.com/Cadoles/emissary/internal/auth/user" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +func (m *Mount) queryTenants(w http.ResponseWriter, r *http.Request) { + baseUser, ok := assertRequestUser(w, r) + if !ok { + return + } + + ctx := r.Context() + + user, ok := baseUser.(*userAuth.User) + if !ok { + logger.Error(ctx, "unexpected user type", logger.F("user", baseUser)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + limit, ok := getIntQueryParam(w, r, "limit", 10) + if !ok { + return + } + + offset, ok := getIntQueryParam(w, r, "offset", 0) + if !ok { + return + } + + options := []datastore.TenantQueryOptionFunc{ + datastore.WithTenantQueryLimit(int(limit)), + datastore.WithTenantQueryOffset(int(offset)), + } + + ids, ok := getStringSliceValues(w, r, "ids", nil) + if !ok { + return + } + + tenantIDs := make([]datastore.TenantID, 0) + + if user.Role() != userAuth.RoleAdmin { + tenantIDs = append(tenantIDs, user.Tenant()) + } + + for _, id := range ids { + tenantIDs = append(tenantIDs, datastore.TenantID(id)) + } + + if len(tenantIDs) > 0 { + options = append(options, datastore.WithTenantQueryID(tenantIDs...)) + } + + tenants, total, err := m.tenantRepo.Query( + ctx, + options..., + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not list tenants", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Tenants []*datastore.Tenant `json:"tenants"` + Total int `json:"total"` + }{ + Tenants: tenants, + Total: total, + }) +} diff --git a/internal/server/api/register_agent.go b/internal/server/api/register_agent.go new file mode 100644 index 0000000..891355c --- /dev/null +++ b/internal/server/api/register_agent.go @@ -0,0 +1,151 @@ +package api + +import ( + "encoding/json" + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/agent/metadata" + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "forge.cadoles.com/Cadoles/emissary/internal/jwk" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type registerAgentRequest struct { + KeySet json.RawMessage `json:"keySet" validate:"required"` + Metadata []metadata.Tuple `json:"metadata" validate:"required"` + Thumbprint string `json:"thumbprint" validate:"required"` + Signature string `json:"signature" validate:"required"` +} + +func (m *Mount) registerAgent(w http.ResponseWriter, r *http.Request) { + registerAgentReq := ®isterAgentRequest{} + if ok := api.Bind(w, r, registerAgentReq); !ok { + return + } + + ctx := r.Context() + + keySet, err := jwk.Parse(registerAgentReq.KeySet) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not parse key set", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + ctx = logger.With(ctx, logger.F("agentThumbprint", registerAgentReq.Thumbprint)) + + // Validate that the existing signature validates the request + + validSignature, err := jwk.Verify(keySet, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not validate signature", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + if !validSignature { + logger.Warn(ctx, "conflicting signature", logger.F("signature", registerAgentReq.Signature)) + api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil) + + return + } + + metadata := metadata.FromSorted(registerAgentReq.Metadata) + + agent, err := m.agentRepo.Create( + ctx, + registerAgentReq.Thumbprint, + keySet, + metadata, + ) + if err != nil { + if !errors.Is(err, datastore.ErrAlreadyExist) { + err = errors.WithStack(err) + logger.Error(ctx, "could not create agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + agents, _, err := m.agentRepo.Query( + ctx, + datastore.WithAgentQueryThumbprints(registerAgentReq.Thumbprint), + datastore.WithAgentQueryLimit(1), + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not retrieve agents", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + if len(agents) == 0 { + err = errors.WithStack(err) + logger.Error(ctx, "could not retrieve matching agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeNotFound, nil) + + return + } + + agentID := agents[0].ID + + agent, err = m.agentRepo.Get(ctx, agentID) + if err != nil { + err = errors.WithStack(err) + logger.Error( + ctx, "could not retrieve agent", + logger.CapturedE(err), logger.F("agentID", agentID), + ) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + validSignature, err = jwk.Verify(agent.KeySet.Set, registerAgentReq.Signature, registerAgentReq.Thumbprint, registerAgentReq.Metadata) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not validate signature using previous keyset", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusConflict, ErrCodeConflict, nil) + + return + } + + if !validSignature { + logger.Error(ctx, "invalid signature") + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, nil) + + return + } + + agent, err = m.agentRepo.Update( + ctx, + agents[0].ID, + datastore.WithAgentUpdateKeySet(keySet), + datastore.WithAgentUpdateMetadata(metadata), + datastore.WithAgentUpdateThumbprint(registerAgentReq.Thumbprint), + ) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not update agent", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + } + + api.DataResponse(w, http.StatusCreated, struct { + Agent *datastore.Agent `json:"agent"` + }{ + Agent: agent, + }) +} diff --git a/internal/server/api/release_agent.go b/internal/server/api/release_agent.go new file mode 100644 index 0000000..ceeda6f --- /dev/null +++ b/internal/server/api/release_agent.go @@ -0,0 +1,58 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type releaseAgentRequest struct { + AgentID int64 `json:"agentId" validate:"required"` +} + +func (m *Mount) releaseAgent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + releaseAgentReq := &releaseAgentRequest{} + if ok := api.Bind(w, r, releaseAgentReq); !ok { + return + } + + agentID := datastore.AgentID(releaseAgentReq.AgentID) + + if ok := m.assertTenantOwns(w, r, agentID); !ok { + return + } + + agent, err := m.agentRepo.Get(ctx, agentID) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not retrieve agent", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + agent, err = m.agentRepo.Detach(ctx, agent.ID) + if err != nil { + err = errors.WithStack(err) + logger.Error(ctx, "could not detach 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, + }) +} diff --git a/internal/server/api/update_agent.go b/internal/server/api/update_agent.go new file mode 100644 index 0000000..778a1c7 --- /dev/null +++ b/internal/server/api/update_agent.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type updateAgentRequest struct { + Status *datastore.AgentStatus `json:"status" validate:"omitempty,oneof=0 1 2 3"` + Label *string `json:"label" validate:"omitempty"` +} + +func (m *Mount) updateAgent(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + ctx := r.Context() + + updateAgentReq := &updateAgentRequest{} + if ok := api.Bind(w, r, updateAgentReq); !ok { + return + } + + options := make([]datastore.AgentUpdateOptionFunc, 0) + + if updateAgentReq.Status != nil { + options = append(options, datastore.WithAgentUpdateStatus(*updateAgentReq.Status)) + } + + if updateAgentReq.Label != nil { + options = append(options, datastore.WithAgentUpdateLabel(*updateAgentReq.Label)) + } + + agent, err := m.agentRepo.Update( + ctx, + datastore.AgentID(agentID), + options..., + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not update 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, + }) +} diff --git a/internal/server/api/update_spec.go b/internal/server/api/update_spec.go new file mode 100644 index 0000000..9a588e5 --- /dev/null +++ b/internal/server/api/update_spec.go @@ -0,0 +1,86 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "forge.cadoles.com/Cadoles/emissary/internal/spec" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +const ( + ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision" +) + +type updateSpecRequest struct { + spec.RawSpec +} + +func (m *Mount) updateSpec(w http.ResponseWriter, r *http.Request) { + agentID, ok := getAgentID(w, r) + if !ok { + return + } + + ctx := r.Context() + + updateSpecReq := &updateSpecRequest{} + if ok := api.Bind(w, r, updateSpecReq); !ok { + return + } + + if err := spec.Validate(ctx, updateSpecReq); err != nil { + data := struct { + Message string `json:"message"` + }{} + + var validationErr *spec.ValidationError + + if errors.As(err, &validationErr) { + data.Message = validationErr.Error() + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not validate spec", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, data) + + return + } + + spec, err := m.agentRepo.UpdateSpec( + ctx, + datastore.AgentID(agentID), + string(updateSpecReq.SpecName()), + updateSpecReq.SpecRevision(), + updateSpecReq.SpecData(), + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + if errors.Is(err, datastore.ErrUnexpectedRevision) { + api.ErrorResponse(w, http.StatusConflict, ErrCodeUnexpectedRevision, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not update spec", logger.CapturedE(err)) + + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Spec *datastore.Spec `json:"spec"` + }{ + Spec: spec, + }) +} diff --git a/internal/server/api/update_tenant.go b/internal/server/api/update_tenant.go new file mode 100644 index 0000000..9665ccb --- /dev/null +++ b/internal/server/api/update_tenant.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/api" + "gitlab.com/wpetit/goweb/logger" +) + +type UpdateTenantRequest struct { + Label *string `json:"label" validate:"omitempty"` +} + +func (m *Mount) updateTenant(w http.ResponseWriter, r *http.Request) { + tenantID, ok := getTenantID(w, r) + if !ok { + return + } + + ctx := r.Context() + + updateTenantReq := &UpdateTenantRequest{} + if ok := api.Bind(w, r, updateTenantReq); !ok { + return + } + + options := make([]datastore.TenantUpdateOptionFunc, 0) + + if updateTenantReq.Label != nil { + options = append(options, datastore.WithTenantUpdateLabel(*updateTenantReq.Label)) + } + + tenant, err := m.tenantRepo.Update( + ctx, + tenantID, + options..., + ) + if err != nil { + if errors.Is(err, datastore.ErrNotFound) { + api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) + + return + } + + err = errors.WithStack(err) + logger.Error(ctx, "could not update tenant", logger.CapturedE(err)) + api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) + + return + } + + api.DataResponse(w, http.StatusOK, struct { + Tenant *datastore.Tenant `json:"tenant"` + }{ + Tenant: tenant, + }) +} diff --git a/internal/server/authorization.go b/internal/server/authorization.go deleted file mode 100644 index 643a58a..0000000 --- a/internal/server/authorization.go +++ /dev/null @@ -1,156 +0,0 @@ -package server - -import ( - "context" - "fmt" - "net/http" - - "forge.cadoles.com/Cadoles/emissary/internal/auth" - "forge.cadoles.com/Cadoles/emissary/internal/auth/agent" - "forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/api" - "gitlab.com/wpetit/goweb/logger" -) - -var ErrCodeForbidden api.ErrorCode = "forbidden" - -func assertGlobalReadAccess(h http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - reqUser, ok := assertRequestUser(w, r) - if !ok { - return - } - - switch user := reqUser.(type) { - case *thirdparty.User: - role := user.Role() - if role == thirdparty.RoleReader || role == thirdparty.RoleWriter { - h.ServeHTTP(w, r) - - return - } - - case *agent.User: - // Agents dont have global read access - - default: - logUnexpectedUserType(r.Context(), reqUser) - } - - forbidden(w, r) - } - - return http.HandlerFunc(fn) -} - -func assertAgentWriteAccess(h http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - reqUser, ok := assertRequestUser(w, r) - if !ok { - return - } - - agentID, ok := getAgentID(w, r) - if !ok { - return - } - - switch user := reqUser.(type) { - case *thirdparty.User: - role := user.Role() - if role == thirdparty.RoleWriter { - h.ServeHTTP(w, r) - - return - } - - case *agent.User: - if user.Agent().ID == agentID { - h.ServeHTTP(w, r) - - return - } - - default: - logUnexpectedUserType(r.Context(), reqUser) - } - - forbidden(w, r) - } - - return http.HandlerFunc(fn) -} - -func assertAgentReadAccess(h http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - reqUser, ok := assertRequestUser(w, r) - if !ok { - return - } - - agentID, ok := getAgentID(w, r) - if !ok { - return - } - - switch user := reqUser.(type) { - case *thirdparty.User: - role := user.Role() - if role == thirdparty.RoleReader || role == thirdparty.RoleWriter { - h.ServeHTTP(w, r) - - return - } - - case *agent.User: - if user.Agent().ID == agentID { - h.ServeHTTP(w, r) - - return - } - - default: - logUnexpectedUserType(r.Context(), reqUser) - } - - forbidden(w, r) - } - - return http.HandlerFunc(fn) -} - -func assertRequestUser(w http.ResponseWriter, r *http.Request) (auth.User, bool) { - ctx := r.Context() - user, err := auth.CtxUser(ctx) - if err != nil { - err = errors.WithStack(err) - logger.Error(ctx, "could not retrieve user", logger.CapturedE(err)) - - forbidden(w, r) - - return nil, false - } - - if user == nil { - forbidden(w, r) - - return nil, false - } - - return user, true -} - -func forbidden(w http.ResponseWriter, r *http.Request) { - logger.Warn(r.Context(), "forbidden", logger.F("path", r.URL.Path)) - - api.ErrorResponse(w, http.StatusForbidden, ErrCodeForbidden, nil) -} - -func logUnexpectedUserType(ctx context.Context, user auth.User) { - logger.Warn( - ctx, "unexpected user type", - logger.F("subject", user.Subject()), - logger.F("type", fmt.Sprintf("%T", user)), - ) -} diff --git a/internal/server/init.go b/internal/server/init.go index b1be69f..72002c8 100644 --- a/internal/server/init.go +++ b/internal/server/init.go @@ -13,7 +13,13 @@ func (s *Server) initRepositories(ctx context.Context) error { return errors.WithStack(err) } + tenantRepo, err := setup.NewTenantRepository(ctx, s.conf.Database) + if err != nil { + return errors.WithStack(err) + } + s.agentRepo = agentRepo + s.tenantRepo = tenantRepo return nil } diff --git a/internal/server/server.go b/internal/server/server.go index 41d0af9..e51468a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,12 +10,12 @@ import ( "strings" "time" - "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/auth/user" "forge.cadoles.com/Cadoles/emissary/internal/config" "forge.cadoles.com/Cadoles/emissary/internal/datastore" "forge.cadoles.com/Cadoles/emissary/internal/jwk" + "forge.cadoles.com/Cadoles/emissary/internal/server/api" "github.com/antonmedv/expr" "github.com/antonmedv/expr/vm" "github.com/go-chi/chi/v5" @@ -28,8 +28,9 @@ import ( ) type Server struct { - conf config.ServerConfig - agentRepo datastore.AgentRepository + conf config.ServerConfig + agentRepo datastore.AgentRepository + tenantRepo datastore.TenantRepository } func (s *Server) Start(ctx context.Context) (<-chan net.Addr, <-chan error) { @@ -93,7 +94,7 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e router.Use(corsMiddleware.Handler) - thirdPartyAuth, err := s.getThirdPartyAuthenticator() + userAuth, err := s.getUserAuthenticator() if err != nil { errs <- errors.WithStack(err) @@ -101,25 +102,14 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e } router.Route("/api/v1", func(r chi.Router) { - r.Post("/register", s.registerAgent) + apiMount := api.NewMount( + s.agentRepo, + s.tenantRepo, + userAuth, + agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew), + ) - r.Group(func(r chi.Router) { - r.Use(auth.Middleware( - thirdPartyAuth, - agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew), - )) - - r.Route("/agents", func(r chi.Router) { - 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) - - r.With(assertAgentReadAccess).Get("/{agentID}/specs", s.getAgentSpecs) - r.With(assertAgentWriteAccess).Post("/{agentID}/specs", s.updateSpec) - r.With(assertAgentWriteAccess).Delete("/{agentID}/specs", s.deleteSpec) - }) - }) + apiMount.Mount(r) }) logger.Info(ctx, "http server listening") @@ -131,7 +121,7 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e logger.Info(ctx, "http server exiting") } -func (s *Server) getThirdPartyAuthenticator() (*thirdparty.Authenticator, error) { +func (s *Server) getUserAuthenticator() (*user.Authenticator, error) { var localPublicKey jwk.Key localAuth := s.conf.Auth.Local @@ -153,7 +143,7 @@ func (s *Server) getThirdPartyAuthenticator() (*thirdparty.Authenticator, error) localPublicKey = publicKey } - var getRemoteKeySet thirdparty.GetKeySet + var getRemoteKeySet user.GetKeySet remoteAuth := s.conf.Auth.Remote if remoteAuth != nil { @@ -205,18 +195,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 user.NewAuthenticator(getKeySet, getTokenRole, getTenantRole, user.DefaultAcceptableSkew), nil +} + +var ruleFuncs = []expr.Option{ + expr.Function( "str", func(params ...any) (any, error) { var builder strings.Builder @@ -230,14 +218,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 +274,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/internal/server/spec_api.go b/internal/server/spec_api.go deleted file mode 100644 index 86b63b1..0000000 --- a/internal/server/spec_api.go +++ /dev/null @@ -1,179 +0,0 @@ -package server - -import ( - "net/http" - "strconv" - - "forge.cadoles.com/Cadoles/emissary/internal/datastore" - "forge.cadoles.com/Cadoles/emissary/internal/spec" - "github.com/go-chi/chi/v5" - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/api" - "gitlab.com/wpetit/goweb/logger" -) - -const ( - ErrCodeUnexpectedRevision api.ErrorCode = "unexpected-revision" -) - -type updateSpecRequest struct { - spec.RawSpec -} - -func (s *Server) updateSpec(w http.ResponseWriter, r *http.Request) { - agentID, ok := getAgentID(w, r) - if !ok { - return - } - - ctx := r.Context() - - updateSpecReq := &updateSpecRequest{} - if ok := api.Bind(w, r, updateSpecReq); !ok { - return - } - - if err := spec.Validate(ctx, updateSpecReq); err != nil { - data := struct { - Message string `json:"message"` - }{} - - var validationErr *spec.ValidationError - - if errors.As(err, &validationErr) { - data.Message = validationErr.Error() - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not validate spec", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeInvalidRequest, data) - - return - } - - spec, err := s.agentRepo.UpdateSpec( - ctx, - datastore.AgentID(agentID), - string(updateSpecReq.SpecName()), - updateSpecReq.SpecRevision(), - updateSpecReq.SpecData(), - ) - if err != nil { - if errors.Is(err, datastore.ErrNotFound) { - api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) - - return - } - - if errors.Is(err, datastore.ErrUnexpectedRevision) { - api.ErrorResponse(w, http.StatusConflict, ErrCodeUnexpectedRevision, nil) - - return - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not update spec", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - api.DataResponse(w, http.StatusOK, struct { - Spec *datastore.Spec `json:"spec"` - }{ - Spec: spec, - }) -} - -func (s *Server) getAgentSpecs(w http.ResponseWriter, r *http.Request) { - agentID, ok := getAgentID(w, r) - if !ok { - return - } - - ctx := r.Context() - - specs, err := s.agentRepo.GetSpecs(ctx, agentID) - if err != nil { - if errors.Is(err, datastore.ErrNotFound) { - api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) - - return - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not list specs", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - api.DataResponse(w, http.StatusOK, struct { - Specs []*datastore.Spec `json:"specs"` - }{ - Specs: specs, - }) -} - -type deleteSpecRequest struct { - Name string `json:"name"` -} - -func (s *Server) deleteSpec(w http.ResponseWriter, r *http.Request) { - agentID, ok := getAgentID(w, r) - if !ok { - return - } - - deleteSpecReq := &deleteSpecRequest{} - if ok := api.Bind(w, r, deleteSpecReq); !ok { - return - } - - ctx := r.Context() - - err := s.agentRepo.DeleteSpec( - ctx, - agentID, - deleteSpecReq.Name, - ) - if err != nil { - if errors.Is(err, datastore.ErrNotFound) { - api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) - - return - } - - err = errors.WithStack(err) - logger.Error(ctx, "could not delete spec", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusInternalServerError, ErrCodeUnknownError, nil) - - return - } - - api.DataResponse(w, http.StatusOK, struct { - Name string `json:"name"` - }{ - Name: deleteSpecReq.Name, - }) -} - -func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) { - rawSpecID := chi.URLParam(r, "") - - specID, err := strconv.ParseInt(rawSpecID, 10, 64) - if err != nil { - err = errors.WithStack(err) - logger.Error(r.Context(), "could not parse spec id", logger.CapturedE(err)) - - api.ErrorResponse(w, http.StatusBadRequest, api.ErrCodeMalformedRequest, nil) - - return 0, false - } - - return datastore.SpecID(specID), true -} diff --git a/internal/setup/repository.go b/internal/setup/repository.go index 611cff3..3854db0 100644 --- a/internal/setup/repository.go +++ b/internal/setup/repository.go @@ -67,3 +67,40 @@ func NewAgentRepository(ctx context.Context, conf config.DatabaseConfig) (datast return agentRepository, nil } + +func NewTenantRepository(ctx context.Context, conf config.DatabaseConfig) (datastore.TenantRepository, error) { + driver := string(conf.Driver) + dsn := string(conf.DSN) + + var tenantRepository datastore.TenantRepository + + logger.Debug(ctx, "initializing tenant repository", logger.F("driver", driver), logger.F("dsn", dsn)) + + switch driver { + case config.DatabaseDriverPostgres: + // TODO + // pool, err := openPostgresPool(ctx, dsn) + // if err != nil { + // return nil, errors.WithStack(err) + // } + + // entryRepository = postgres.NewEntryRepository(pool) + case config.DatabaseDriverSQLite: + url, err := url.Parse(dsn) + if err != nil { + return nil, errors.WithStack(err) + } + + db, err := sql.Open(driver, url.Host+url.Path) + if err != nil { + return nil, errors.WithStack(err) + } + + tenantRepository = sqlite.NewTenantRepository(db, 5) + + default: + return nil, errors.Errorf("unsupported database driver '%s'", driver) + } + + return tenantRepository, nil +} 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..c272199 --- /dev/null +++ b/migrations/sqlite/0000003_tenant.up.sql @@ -0,0 +1,52 @@ +CREATE TABLE tenants ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL +); + +-- 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/modd.conf b/modd.conf index 4afbd7e..1a3238c 100644 --- a/modd.conf +++ b/modd.conf @@ -8,7 +8,6 @@ tmp/config.yml prep: make tmp/server.yml prep: make tmp/agent.yml prep: make run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server database migrate" - prep: make .emissary-token daemon: make run-emissary-server EMISSARY_CMD="--debug --config tmp/server.yml server run" daemon: make run-emissary-agent EMISSARY_CMD="--debug --config tmp/agent.yml agent run" } \ No newline at end of file diff --git a/pkg/client/alias.go b/pkg/client/alias.go index cad0060..0fef79c 100644 --- a/pkg/client/alias.go +++ b/pkg/client/alias.go @@ -15,6 +15,8 @@ type ( type ( AgentID = datastore.AgentID Agent = datastore.Agent + TenantID = datastore.TenantID + Tenant = datastore.Tenant AgentStatus = datastore.AgentStatus ) 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 +} diff --git a/pkg/client/create_tenant.go b/pkg/client/create_tenant.go new file mode 100644 index 0000000..4b89390 --- /dev/null +++ b/pkg/client/create_tenant.go @@ -0,0 +1,28 @@ +package client + +import ( + "context" + + "forge.cadoles.com/Cadoles/emissary/internal/server/api" + "github.com/pkg/errors" +) + +func (c *Client) CreateTenant(ctx context.Context, label string, funcs ...OptionFunc) (*Tenant, error) { + response := withResponse[struct { + Tenant *Tenant `json:"tenant"` + }]() + + payload := api.CreateTenantRequest{ + Label: label, + } + + if err := c.apiPost(ctx, "/api/v1/tenants", payload, &response, funcs...); err != nil { + return nil, errors.WithStack(err) + } + + if response.Error != nil { + return nil, errors.WithStack(response.Error) + } + + return response.Data.Tenant, nil +} diff --git a/pkg/client/delete_tenant.go b/pkg/client/delete_tenant.go new file mode 100644 index 0000000..66a736a --- /dev/null +++ b/pkg/client/delete_tenant.go @@ -0,0 +1,26 @@ +package client + +import ( + "context" + "fmt" + + "github.com/pkg/errors" +) + +func (c *Client) DeleteTenant(ctx context.Context, tenantID TenantID, funcs ...OptionFunc) (TenantID, error) { + response := withResponse[struct { + TenantID string `json:"tenantId"` + }]() + + path := fmt.Sprintf("/api/v1/tenants/%s", tenantID) + + if err := c.apiDelete(ctx, path, nil, &response, funcs...); err != nil { + return "", errors.WithStack(err) + } + + if response.Error != nil { + return "", errors.WithStack(response.Error) + } + + return TenantID(response.Data.TenantID), nil +} diff --git a/pkg/client/get_tenant.go b/pkg/client/get_tenant.go new file mode 100644 index 0000000..89519ee --- /dev/null +++ b/pkg/client/get_tenant.go @@ -0,0 +1,26 @@ +package client + +import ( + "context" + "fmt" + + "github.com/pkg/errors" +) + +func (c *Client) GetTenant(ctx context.Context, tenantID TenantID, funcs ...OptionFunc) (*Tenant, error) { + response := withResponse[struct { + Tenant *Tenant `json:"tenant"` + }]() + + path := fmt.Sprintf("/api/v1/tenants/%s", tenantID) + + if err := c.apiGet(ctx, path, &response, funcs...); err != nil { + return nil, errors.WithStack(err) + } + + if response.Error != nil { + return nil, errors.WithStack(response.Error) + } + + return response.Data.Tenant, nil +} diff --git a/pkg/client/query_tenants.go b/pkg/client/query_tenants.go new file mode 100644 index 0000000..5bf6394 --- /dev/null +++ b/pkg/client/query_tenants.go @@ -0,0 +1,77 @@ +package client + +import ( + "context" + "fmt" + "net/url" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" +) + +type QueryTenantsOptionFunc func(*QueryTenantsOptions) + +type QueryTenantsOptions struct { + Options []OptionFunc + Limit *int + Offset *int + IDs []TenantID +} + +func WithQueryTenantsOptions(funcs ...OptionFunc) QueryTenantsOptionFunc { + return func(opts *QueryTenantsOptions) { + opts.Options = funcs + } +} + +func WithQueryTenantsLimit(limit int) QueryTenantsOptionFunc { + return func(opts *QueryTenantsOptions) { + opts.Limit = &limit + } +} + +func WithQueryTenantsOffset(offset int) QueryTenantsOptionFunc { + return func(opts *QueryTenantsOptions) { + opts.Offset = &offset + } +} + +func WithQueryTenantsID(ids ...datastore.TenantID) QueryTenantsOptionFunc { + return func(opts *QueryTenantsOptions) { + opts.IDs = ids + } +} + +func (c *Client) QueryTenants(ctx context.Context, funcs ...QueryTenantsOptionFunc) ([]*Tenant, int, error) { + options := &QueryTenantsOptions{} + for _, fn := range funcs { + fn(options) + } + + query := url.Values{} + + if options.IDs != nil && len(options.IDs) > 0 { + query.Set("ids", joinSlice(options.IDs)) + } + + path := fmt.Sprintf("/api/v1/tenants?%s", query.Encode()) + + response := withResponse[struct { + Tenants []*datastore.Tenant `json:"tenants"` + Total int `json:"total"` + }]() + + if options.Options == nil { + options.Options = make([]OptionFunc, 0) + } + + if err := c.apiGet(ctx, path, &response, options.Options...); err != nil { + return nil, 0, errors.WithStack(err) + } + + if response.Error != nil { + return nil, 0, errors.WithStack(response.Error) + } + + return response.Data.Tenants, response.Data.Total, nil +} diff --git a/pkg/client/update_agent.go b/pkg/client/update_agent.go index 0ddeec5..39f57c3 100644 --- a/pkg/client/update_agent.go +++ b/pkg/client/update_agent.go @@ -34,7 +34,7 @@ func WithUpdateAgentsOptions(funcs ...OptionFunc) UpdateAgentOptionFunc { } } -func (c *Client) UpdateAgent(ctx context.Context, agentID datastore.AgentID, funcs ...UpdateAgentOptionFunc) (*datastore.Agent, error) { +func (c *Client) UpdateAgent(ctx context.Context, agentID AgentID, funcs ...UpdateAgentOptionFunc) (*datastore.Agent, error) { opts := &UpdateAgentOptions{} for _, fn := range funcs { fn(opts) diff --git a/pkg/client/update_tenant.go b/pkg/client/update_tenant.go new file mode 100644 index 0000000..d1d4da9 --- /dev/null +++ b/pkg/client/update_tenant.go @@ -0,0 +1,61 @@ +package client + +import ( + "context" + "fmt" + + "forge.cadoles.com/Cadoles/emissary/internal/datastore" + "github.com/pkg/errors" +) + +type UpdateTenantOptions struct { + Label *string + Options []OptionFunc +} + +type UpdateTenantOptionFunc func(*UpdateTenantOptions) + +func WithTenantLabel(label string) UpdateTenantOptionFunc { + return func(opts *UpdateTenantOptions) { + opts.Label = &label + } +} + +func WithUpdateTenantOptions(funcs ...OptionFunc) UpdateTenantOptionFunc { + return func(opts *UpdateTenantOptions) { + opts.Options = funcs + } +} + +func (c *Client) UpdateTenant(ctx context.Context, tenantID TenantID, funcs ...UpdateTenantOptionFunc) (*Tenant, error) { + opts := &UpdateTenantOptions{} + for _, fn := range funcs { + fn(opts) + } + + payload := map[string]any{} + + if opts.Label != nil { + payload["label"] = *opts.Label + } + + response := withResponse[struct { + Tenant *datastore.Tenant `json:"tenant"` + }]() + + path := fmt.Sprintf("/api/v1/tenants/%s", tenantID) + + if opts.Options == nil { + opts.Options = make([]OptionFunc, 0) + } + + if err := c.apiPut(ctx, path, payload, &response, opts.Options...); err != nil { + return nil, errors.WithStack(err) + } + + if response.Error != nil { + return nil, errors.WithStack(response.Error) + } + + return response.Data.Tenant, nil +}