From c851a1f51bfea3cdcfeccea2100f57951393bf5c Mon Sep 17 00:00:00 2001 From: William Petit Date: Tue, 27 Feb 2024 14:14:30 +0100 Subject: [PATCH] feat(client): tenant management commands --- .gitignore | 2 + Makefile | 24 +- README.md | 20 ++ cmd/agent/main.go | 4 +- cmd/server/main.go | 4 +- internal/auth/agent/authenticator.go | 2 +- .../{thirdparty => user}/authenticator.go | 2 +- internal/auth/{thirdparty => user}/jwt.go | 2 +- internal/auth/{thirdparty => user}/user.go | 5 +- internal/command/agent/run.go | 4 + internal/command/api/root.go | 16 -- .../command/{api => client}/agent/claim.go | 4 +- .../command/{api => client}/agent/count.go | 4 +- .../command/{api => client}/agent/delete.go | 6 +- .../{api => client}/agent/flag/flag.go | 2 +- internal/command/{api => client}/agent/get.go | 6 +- .../agent/util.go => client/agent/hints.go} | 1 - .../command/{api => client}/agent/query.go | 4 +- .../command/{api => client}/agent/root.go | 2 +- .../{api => client}/agent/spec/delete.go | 6 +- .../command/{api => client}/agent/spec/get.go | 6 +- .../{api => client}/agent/spec/root.go | 0 .../{api => client}/agent/spec/update.go | 6 +- .../command/{api => client}/agent/update.go | 6 +- .../command/{api => client}/apierr/wrap.go | 0 internal/command/{api => client}/flag/flag.go | 0 internal/command/{api => client}/flag/util.go | 0 internal/command/client/root.go | 18 ++ internal/command/client/tenant/create.go | 51 ++++ internal/command/client/tenant/flag/flag.go | 37 +++ internal/command/client/tenant/get.go | 49 ++++ internal/command/client/tenant/hints.go | 18 ++ internal/command/client/tenant/root.go | 17 ++ internal/command/client/tenant/update.go | 62 +++++ internal/command/server/auth/create_token.go | 20 +- internal/config/server.go | 6 +- internal/datastore/sqlite/agent_repository.go | 87 +------ internal/datastore/sqlite/repository.go | 97 ++++++++ .../datastore/sqlite/tenant_repository.go | 202 +++++++++++++++ .../sqlite/tenant_repository_test.go | 46 ++++ internal/datastore/tenant_repository.go | 22 ++ .../datastore/testsuite/tenant_repository.go | 14 ++ .../testsuite/tenant_repository_cases.go | 109 +++++++++ internal/server/api/authorization.go | 230 ++++++++++++------ internal/server/api/create_tenant.go | 38 +++ internal/server/api/delete_tenant.go | 43 ++++ internal/server/api/get_tenant.go | 40 +++ internal/server/api/helper.go | 18 +- internal/server/api/mount.go | 28 ++- internal/server/api/update_agent.go | 6 + internal/server/api/update_tenant.go | 59 +++++ internal/server/init.go | 6 + internal/server/server.go | 18 +- internal/setup/repository.go | 37 +++ migrations/sqlite/0000003_tenant.up.sql | 12 - modd.conf | 1 - pkg/client/alias.go | 2 + pkg/client/create_tenant.go | 28 +++ pkg/client/get_tenant.go | 26 ++ pkg/client/update_agent.go | 2 +- pkg/client/update_tenant.go | 61 +++++ 61 files changed, 1376 insertions(+), 272 deletions(-) rename internal/auth/{thirdparty => user}/authenticator.go (99%) rename internal/auth/{thirdparty => user}/jwt.go (98%) rename internal/auth/{thirdparty => user}/user.go (85%) delete mode 100644 internal/command/api/root.go rename internal/command/{api => client}/agent/claim.go (93%) rename internal/command/{api => client}/agent/count.go (93%) rename internal/command/{api => client}/agent/delete.go (92%) rename internal/command/{api => client}/agent/flag/flag.go (98%) rename internal/command/{api => client}/agent/get.go (91%) rename internal/command/{api/agent/util.go => client/agent/hints.go} (87%) rename internal/command/{api => client}/agent/query.go (96%) rename internal/command/{api => client}/agent/root.go (81%) rename internal/command/{api => client}/agent/spec/delete.go (93%) rename internal/command/{api => client}/agent/spec/get.go (92%) rename internal/command/{api => client}/agent/spec/root.go (100%) rename internal/command/{api => client}/agent/spec/update.go (97%) rename internal/command/{api => client}/agent/update.go (94%) rename internal/command/{api => client}/apierr/wrap.go (100%) rename internal/command/{api => client}/flag/flag.go (100%) rename internal/command/{api => client}/flag/util.go (100%) create mode 100644 internal/command/client/root.go create mode 100644 internal/command/client/tenant/create.go create mode 100644 internal/command/client/tenant/flag/flag.go create mode 100644 internal/command/client/tenant/get.go create mode 100644 internal/command/client/tenant/hints.go create mode 100644 internal/command/client/tenant/root.go create mode 100644 internal/command/client/tenant/update.go create mode 100644 internal/datastore/sqlite/repository.go create mode 100644 internal/datastore/sqlite/tenant_repository.go create mode 100644 internal/datastore/sqlite/tenant_repository_test.go create mode 100644 internal/datastore/tenant_repository.go create mode 100644 internal/datastore/testsuite/tenant_repository.go create mode 100644 internal/datastore/testsuite/tenant_repository_cases.go create mode 100644 internal/server/api/create_tenant.go create mode 100644 internal/server/api/delete_tenant.go create mode 100644 internal/server/api/get_tenant.go create mode 100644 internal/server/api/update_tenant.go create mode 100644 pkg/client/create_tenant.go create mode 100644 pkg/client/get_tenant.go create mode 100644 pkg/client/update_tenant.go 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 5555d06..cce127f 100644 --- a/Makefile +++ b/Makefile @@ -122,19 +122,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 --tenant '00000000-0000-0000-0000-000000000000'" +.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 -claim-agent: - go run ./cmd/server api agent claim --agent-thumbprint $(shell go run ./cmd/agent agent show-thumbprint) +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: - cat misc/spec-samples/app.emissary.cadoles.com.json | go run ./cmd/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name app.emissary.cadoles.com - cat misc/spec-samples/proxy.emissary.cadoles.com.json | go run ./cmd/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name proxy.emissary.cadoles.com - cat misc/spec-samples/mdns.emissary.cadoles.com.json | go run ./cmd/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name mdns.emissary.cadoles.com - cat misc/spec-samples/uci.emissary.cadoles.com.json | go run ./cmd/server api agent spec update -a $(AGENT_ID) --no-patch --spec-data - --spec-name uci.emissary.cadoles.com +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..0f6acd8 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,26 @@ 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 a second terminal, create a api token, an admin token and a tenant +make .emissary-token + +# Claim the agent for your newly created tenant +make claim-agent + +# Query you agents +./bin/server client agent query +``` + ## 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/internal/auth/agent/authenticator.go b/internal/auth/agent/authenticator.go index e8af156..5d082c6 100644 --- a/internal/auth/agent/authenticator.go +++ b/internal/auth/agent/authenticator.go @@ -83,7 +83,7 @@ func (a *Authenticator) Authenticate(ctx context.Context, r *http.Request) (auth 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 { diff --git a/internal/auth/thirdparty/authenticator.go b/internal/auth/user/authenticator.go similarity index 99% rename from internal/auth/thirdparty/authenticator.go rename to internal/auth/user/authenticator.go index 803e638..0801ba4 100644 --- a/internal/auth/thirdparty/authenticator.go +++ b/internal/auth/user/authenticator.go @@ -1,4 +1,4 @@ -package thirdparty +package user import ( "context" diff --git a/internal/auth/thirdparty/jwt.go b/internal/auth/user/jwt.go similarity index 98% rename from internal/auth/thirdparty/jwt.go rename to internal/auth/user/jwt.go index b17d58e..411a05d 100644 --- a/internal/auth/thirdparty/jwt.go +++ b/internal/auth/user/jwt.go @@ -1,4 +1,4 @@ -package thirdparty +package user import ( "context" diff --git a/internal/auth/thirdparty/user.go b/internal/auth/user/user.go similarity index 85% rename from internal/auth/thirdparty/user.go rename to internal/auth/user/user.go index ea2eae9..b2e9d49 100644 --- a/internal/auth/thirdparty/user.go +++ b/internal/auth/user/user.go @@ -1,4 +1,4 @@ -package thirdparty +package user import ( "forge.cadoles.com/Cadoles/emissary/internal/auth" @@ -10,12 +10,13 @@ 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 + return rr == RoleWriter || rr == RoleReader || rr == RoleAdmin } type User struct { 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/api/agent/claim.go b/internal/command/client/agent/claim.go similarity index 93% rename from internal/command/api/agent/claim.go rename to internal/command/client/agent/claim.go index 4667aab..7ca7d01 100644 --- a/internal/command/api/agent/claim.go +++ b/internal/command/client/agent/claim.go @@ -3,8 +3,8 @@ 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/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" diff --git a/internal/command/api/agent/count.go b/internal/command/client/agent/count.go similarity index 93% rename from internal/command/api/agent/count.go rename to internal/command/client/agent/count.go index 8d57c50..b11c4cf 100644 --- a/internal/command/api/agent/count.go +++ b/internal/command/client/agent/count.go @@ -3,8 +3,8 @@ 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/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" diff --git a/internal/command/api/agent/delete.go b/internal/command/client/agent/delete.go similarity index 92% rename from internal/command/api/agent/delete.go rename to internal/command/client/agent/delete.go index b260666..b8e5dce 100644 --- a/internal/command/api/agent/delete.go +++ b/internal/command/client/agent/delete.go @@ -3,9 +3,9 @@ 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/pkg/client" "github.com/pkg/errors" 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 91% rename from internal/command/api/agent/get.go rename to internal/command/client/agent/get.go index 18a4349..8af5c99 100644 --- a/internal/command/api/agent/get.go +++ b/internal/command/client/agent/get.go @@ -3,9 +3,9 @@ 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/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" diff --git a/internal/command/api/agent/util.go b/internal/command/client/agent/hints.go similarity index 87% rename from internal/command/api/agent/util.go rename to internal/command/client/agent/hints.go index 478e569..acb75f2 100644 --- a/internal/command/api/agent/util.go +++ b/internal/command/client/agent/hints.go @@ -10,7 +10,6 @@ func agentHints(outputMode format.OutputMode) format.Hints { OutputMode: outputMode, Props: []format.Prop{ format.NewProp("ID", "ID"), - format.NewProp("TenantID", "Tenant", table.WithCompactModeMaxColumnWidth(8)), format.NewProp("Label", "Label"), format.NewProp("Thumbprint", "Thumbprint"), format.NewProp("Status", "Status"), diff --git a/internal/command/api/agent/query.go b/internal/command/client/agent/query.go similarity index 96% rename from internal/command/api/agent/query.go rename to internal/command/client/agent/query.go index 4ccd76b..dfff411 100644 --- a/internal/command/api/agent/query.go +++ b/internal/command/client/agent/query.go @@ -3,8 +3,8 @@ 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/pkg/client" "github.com/pkg/errors" diff --git a/internal/command/api/agent/root.go b/internal/command/client/agent/root.go similarity index 81% rename from internal/command/api/agent/root.go rename to internal/command/client/agent/root.go index 8494be7..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" ) diff --git a/internal/command/api/agent/spec/delete.go b/internal/command/client/agent/spec/delete.go similarity index 93% rename from internal/command/api/agent/spec/delete.go rename to internal/command/client/agent/spec/delete.go index 0e8351b..45d6cac 100644 --- a/internal/command/api/agent/spec/delete.go +++ b/internal/command/client/agent/spec/delete.go @@ -3,9 +3,9 @@ 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" + 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" diff --git a/internal/command/api/agent/spec/get.go b/internal/command/client/agent/spec/get.go similarity index 92% rename from internal/command/api/agent/spec/get.go rename to internal/command/client/agent/spec/get.go index 3a7d250..ea671cb 100644 --- a/internal/command/api/agent/spec/get.go +++ b/internal/command/client/agent/spec/get.go @@ -3,9 +3,9 @@ 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" + 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" 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 97% rename from internal/command/api/agent/spec/update.go rename to internal/command/client/agent/spec/update.go index 983508b..23da653 100644 --- a/internal/command/api/agent/spec/update.go +++ b/internal/command/client/agent/spec/update.go @@ -4,9 +4,9 @@ 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" + 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" diff --git a/internal/command/api/agent/update.go b/internal/command/client/agent/update.go similarity index 94% rename from internal/command/api/agent/update.go rename to internal/command/client/agent/update.go index dfe048c..5df7080 100644 --- a/internal/command/api/agent/update.go +++ b/internal/command/client/agent/update.go @@ -3,9 +3,9 @@ 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/pkg/client" "github.com/pkg/errors" "github.com/urfave/cli/v2" 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 100% rename from internal/command/api/flag/flag.go rename to internal/command/client/flag/flag.go 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/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/root.go b/internal/command/client/tenant/root.go new file mode 100644 index 0000000..1ee1923 --- /dev/null +++ b/internal/command/client/tenant/root.go @@ -0,0 +1,17 @@ +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(), + }, + } +} 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 f369a14..b1c0f7b 100644 --- a/internal/command/server/auth/create_token.go +++ b/internal/command/server/auth/create_token.go @@ -5,12 +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" ) @@ -22,18 +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", - Required: true, + Name: "tenant", + Usage: "associate `TENANT` to the token", + Value: "00000000-0000-0000-0000-000000000000", }, &cli.StringFlag{ Name: "output", @@ -64,7 +64,7 @@ func CreateTokenCommand() *cli.Command { return errors.WithStack(err) } - token, err := thirdparty.GenerateToken(ctx.Context, key, datastore.TenantID(tenant), 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 b3b3510..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 { @@ -36,10 +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) : ''", thirdparty.DefaultTenantKey, thirdparty.DefaultTenantKey), + fmt.Sprintf("jwt.%s != nil ? str(jwt.%s) : ''", user.DefaultTenantKey, user.DefaultTenantKey), }, } } diff --git a/internal/datastore/sqlite/agent_repository.go b/internal/datastore/sqlite/agent_repository.go index a1e0c6c..f9c58b4 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,7 @@ import ( ) type AgentRepository struct { - db *sql.DB - sqliteBusyRetryMaxAttempts int + repository } // Attach implements datastore.AgentRepository. @@ -652,89 +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{} 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/tenant_repository.go b/internal/datastore/sqlite/tenant_repository.go new file mode 100644 index 0000000..57cef93 --- /dev/null +++ b/internal/datastore/sqlite/tenant_repository.go @@ -0,0 +1,202 @@ +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 +} + +// 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_repository.go b/internal/datastore/tenant_repository.go new file mode 100644 index 0000000..009de67 --- /dev/null +++ b/internal/datastore/tenant_repository.go @@ -0,0 +1,22 @@ +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 +} + +type TenantUpdateOptionFunc func(*TenantUpdateOptions) + +type TenantUpdateOptions struct { + Label *string +} + +func WithTenantUpdateLabel(label string) TenantUpdateOptionFunc { + return func(opts *TenantUpdateOptions) { + opts.Label = &label + } +} 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/server/api/authorization.go b/internal/server/api/authorization.go index b8a3fe6..6b7f498 100644 --- a/internal/server/api/authorization.go +++ b/internal/server/api/authorization.go @@ -7,7 +7,7 @@ import ( "forge.cadoles.com/Cadoles/emissary/internal/auth" "forge.cadoles.com/Cadoles/emissary/internal/auth/agent" - "forge.cadoles.com/Cadoles/emissary/internal/auth/thirdparty" + "forge.cadoles.com/Cadoles/emissary/internal/auth/user" "forge.cadoles.com/Cadoles/emissary/internal/datastore" "github.com/pkg/errors" "gitlab.com/wpetit/goweb/api" @@ -16,101 +16,99 @@ import ( 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 assertQueryAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfRoles(user.RoleReader, user.RoleWriter, user.RoleAdmin), + nil, + ) } -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 assertUserWithWriteAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfRoles(user.RoleWriter, user.RoleAdmin), + nil, + ) } -func assertAgentReadAccess(h http.Handler) http.Handler { +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), + assertTenant(), + ), + ), + nil, + ) +} + +func assertAdminOrTenantWriteAccess(h http.Handler) http.Handler { + return assertAuthz( + h, + assertOneOfUser( + assertOneOfRoles(user.RoleAdmin), + assertAllOfUser( + assertOneOfRoles(user.RoleWriter), + assertTenant(), + ), + ), + 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 } - agentID, ok := getAgentID(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) - switch user := reqUser.(type) { - case *thirdparty.User: - role := user.Role() - if role == thirdparty.RoleReader || role == thirdparty.RoleWriter { - h.ServeHTTP(w, r) - - return + return + } } case *agent.User: - if user.Agent().ID == agentID { - h.ServeHTTP(w, r) + if assertAgent != nil { + if ok := assertAgent(w, r, u); ok { + h.ServeHTTP(w, r) - return + return + } } - default: logUnexpectedUserType(r.Context(), reqUser) } @@ -119,6 +117,78 @@ func assertAgentReadAccess(h http.Handler) http.Handler { } 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 assertTenant() 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) { 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_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_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 index 0daeafa..7376e55 100644 --- a/internal/server/api/helper.go +++ b/internal/server/api/helper.go @@ -36,7 +36,7 @@ func getAgentID(w http.ResponseWriter, r *http.Request) (datastore.AgentID, bool } func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) { - rawSpecID := chi.URLParam(r, "") + rawSpecID := chi.URLParam(r, "specID") specID, err := strconv.ParseInt(rawSpecID, 10, 64) if err != nil { @@ -51,6 +51,22 @@ func getSpecID(w http.ResponseWriter, r *http.Request) (datastore.SpecID, bool) 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 != "" { diff --git a/internal/server/api/mount.go b/internal/server/api/mount.go index 00dfa62..2cf231c 100644 --- a/internal/server/api/mount.go +++ b/internal/server/api/mount.go @@ -11,6 +11,7 @@ import ( type Mount struct { agentRepo datastore.AgentRepository + tenantRepo datastore.TenantRepository authenticators []auth.Authenticator } @@ -23,17 +24,24 @@ func (m *Mount) Mount(r chi.Router) { r.Use(auth.Middleware(m.authenticators...)) r.Route("/agents", func(r chi.Router) { - r.Post("/claim", m.claimAgent) + r.With(assertUserWithWriteAccess).Post("/claim", m.claimAgent) - r.With(assertGlobalReadAccess).Get("/", m.queryAgents) + r.With(assertQueryAccess).Get("/", m.queryAgents) - r.With(assertAgentReadAccess).Get("/{agentID}", m.getAgent) - r.With(assertAgentWriteAccess).Put("/{agentID}", m.updateAgent) - r.With(assertAgentWriteAccess).Delete("/{agentID}", m.deleteAgent) + r.With(assertAgentOrUserWithReadAccess).Get("/{agentID}", m.getAgent) + r.With(assertAgentOrUserWithWriteAccess).Put("/{agentID}", m.updateAgent) + r.With(assertUserWithWriteAccess).Delete("/{agentID}", m.deleteAgent) - r.With(assertAgentReadAccess).Get("/{agentID}/specs", m.getAgentSpecs) - r.With(assertAgentWriteAccess).Post("/{agentID}/specs", m.updateSpec) - r.With(assertAgentWriteAccess).Delete("/{agentID}/specs", m.deleteSpec) + 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(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) }) }) } @@ -42,6 +50,6 @@ func (m *Mount) notFound(w http.ResponseWriter, r *http.Request) { api.ErrorResponse(w, http.StatusNotFound, ErrCodeNotFound, nil) } -func NewMount(agentRepo datastore.AgentRepository, authenticators ...auth.Authenticator) *Mount { - return &Mount{agentRepo, authenticators} +func NewMount(agentRepo datastore.AgentRepository, tenantRepo datastore.TenantRepository, authenticators ...auth.Authenticator) *Mount { + return &Mount{agentRepo, tenantRepo, authenticators} } diff --git a/internal/server/api/update_agent.go b/internal/server/api/update_agent.go index 559a026..778a1c7 100644 --- a/internal/server/api/update_agent.go +++ b/internal/server/api/update_agent.go @@ -43,6 +43,12 @@ func (m *Mount) updateAgent(w http.ResponseWriter, r *http.Request) { 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) 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/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 8d2b87e..e51468a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,7 +11,7 @@ import ( "time" "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" @@ -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) @@ -103,7 +104,8 @@ func (s *Server) run(parentCtx context.Context, addrs chan net.Addr, errs chan e router.Route("/api/v1", func(r chi.Router) { apiMount := api.NewMount( s.agentRepo, - thirdPartyAuth, + s.tenantRepo, + userAuth, agent.NewAuthenticator(s.agentRepo, agent.DefaultAcceptableSkew), ) @@ -119,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 @@ -141,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 { @@ -198,7 +200,7 @@ func (s *Server) getThirdPartyAuthenticator() (*thirdparty.Authenticator, error) return nil, errors.WithStack(err) } - return thirdparty.NewAuthenticator(getKeySet, getTokenRole, getTenantRole, thirdparty.DefaultAcceptableSkew), nil + return user.NewAuthenticator(getKeySet, getTokenRole, getTenantRole, user.DefaultAcceptableSkew), nil } var ruleFuncs = []expr.Option{ 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.up.sql b/migrations/sqlite/0000003_tenant.up.sql index 9b0c4c4..c272199 100644 --- a/migrations/sqlite/0000003_tenant.up.sql +++ b/migrations/sqlite/0000003_tenant.up.sql @@ -5,18 +5,6 @@ CREATE TABLE tenants ( updated_at datetime NOT NULL ); --- Insert default tenant - -INSERT INTO tenants - ( id, label, created_at, updated_at ) - VALUES ( - '00000000-0000-0000-0000-000000000000', - 'Default', - date('now'), - date('now') - ) -; - -- Add foreign key to agents ALTER TABLE agents RENAME TO _agents; 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/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/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/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 +}