feat(client): tenant management commands

This commit is contained in:
2024-02-27 14:14:30 +01:00
parent 15a0bf6ecc
commit c851a1f51b
61 changed files with 1376 additions and 272 deletions

View File

@ -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) {

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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,
})
}

View File

@ -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 != "" {

View File

@ -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}
}

View File

@ -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)

View File

@ -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,
})
}

View File

@ -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
}

View File

@ -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{