Написать пост

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2

Аватарка пользователя Алексей Соломонов

Рассказали, как внедрить в веб-приложение уровня Enterprise Application зависимости, аутентификацию и владение сущностью.

План

  1. Вводная
  2. Ветвление репозитория
  3. Внедрение зависимостей
  4. Настройка проекта
  5. Middleware:
    Аутентификация
    RBAC
    Владение сущностью
  6. HTTPS
  7. Тестирование
  8. Итоги

Вводная

Всем привет, сегодня мы продолжаем серию статей о том, как разработать коммерческое веб-приложение с нуля. Код представлен в репозитории.

В предыдущей статье мы поговорили о том, как:

  1. Собрать требования.
  2. Разработать архитектуру.
  3. Выбрать веб-фреймворк.
  4. Задокументировать API.

Очевидно, что разработанный проект еще очень далек от того, чтобы с ним могли работать пользователи:

  1. В жизненном цикле приложений проекта возникают случаи, когда какие то параметры необходимо изменить.
  2. В зависимости от состояния, пользователю доступны разные методы API
  3. Пользователь может получать или изменять только те данные, которыми владеет.

Но прежде чем мы решим описанные проблемы, необходимо понять:

  1. Как поддерживать несколько версий проекта.
  2. Как без труда удовлетворять зависимости одних сервисов от других.

Ветвление репозитория

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 1

На изображении представлен реальный жизненный цикл проекта:

  1. В master работает версия v1.0 на production-серверах.
  2. В версии v1.0 нашли баги и подготовили hotfixes.
  3. Hotfixes влили в master и выложили на production-серверы, получив версию проекта v1.1.
  4. Одновременно с master живет новая версия проекта в develop на develop-серверах.
  5. В нее тоже необходимо донести hotfixes с master.
  6. Кроме того, разработчики заливают в develop новые задачи из веток features.
  7. Затем в какой то момент делается срез develop в ветку release.
  8. Release-ветка стабилизируется, проходит регресс-тестирование и выкладывается на production-сервера.

Данный жизненный цикл прекрасно ложится в стратегию ветвления GitFlow.

Использую эту стратегию в своем проекте мы:

  1. Наводим порядок в именовании веток репозитория:
    a. feature/идентификатор_задачи_в_трекере.
    b. hotfix/идентификатор_задачи_в_трекере.
    c. release/Х.Х.Х.
  2. Завязываем сборки CI/CD на стандартизированные префиксы веток.
  3. Делаем независимые друг от друга сборки фичей, фиксов и релизов .
  4. Получаем ситуацию, в которой поддержка текущей версии не мешает разработке новой версии проекта.

Команда проекта ogon.ru не использует консольную программу git-flow, т.к. она не ложится в один процесс с CI/CD системами типа GitLab, поэтому давайте создадим необходимые ветки сами:

			git checkout -b release/1.0.0 master # зафиксировали версию первой статьи
git checkout -b develop master
git checkout -b feature/article-2 develop # будем разрабатывать вторую статью в отдельной ветке
		

Таким образом получили результат:

  1. Читатели первой статьи получают рабочий пример в ветке release/1.0.0.
  2. Функционал второй статьи будет разрабатываться в ветке feature/article-2.
  3. Соотв никто никому не мешает.

Внедрение зависимостей

Как уже упоминалось в предыдущей статье:

  1. Архитектура разбита на домены
  2. Логика домена реализована в сервисах.
  3. Сервисы зависят от других сервисов.

Давайте рассмотрим cmd/user-server/main.go:

			productService := productsrcimpl.NewProductService()
userService := usersrvimpl.NewUserService()
userProductService := usersrvimpl.NewUserProductService(userService, productService)
err := server.Register(grpc.Descriptor{
    Server:               userapp.NewServer(userService, userProductService),
    GRPCRegistrar:        pbv1.RegisterUserServiceServer,
    GRPCGatewayRegistrar: pbv1.RegisterUserServiceHandlerFromEndpoint,
})
if err != nil {
    l.Fatal(err)
}
		

Этот код плох тем, что мы вручную удовлетворяем зависимости конструкторов:

  1. usersrvimpl.NewUserProductService
  2. userapp.NewServer

Возможно, сейчас проблема не кажется такой серьезной, но в реальном проекте у одного сервиса может быть десяток зависимостей, которые, в свою очередь, могут иметь свои зависимости. В итоге реальный проект имеет большой граф зависимостей, который сложно удовлетворить вручную.

Для решения проблемы существуют популярные пакеты удовлетворяющие зависимости:

  1. uber fx + uber dig.
  2. google wire.

Оба варианта решают проблемы зависимостей, но мы остановились на fx + dig. У fx под капотом рефлексия, и на этапе компиляции мы не увидим ошибок неудовлетворения зависимостей. Однако мы увидим эти ошибки при запуске программы, затем fx предлагает модель приложения и позволяет группировать зависимости. Давайте создадим универсальный конструктор приложения pkg/common/adapter/application/application.go:

			type Application struct {
    ctx     context.Context
    cancel  context.CancelFunc
    fxApp   *fx.App
    logger  log.Logger
    options []fx.Option
}

func New(providers ...interface{}) *Application {
    ctx, cancel := context.WithCancel(context.Background())

            app := &Application{
            options: []fx.Option{
            fx.NopLogger,
        },
        ctx:    ctx,
        cancel: cancel,
    }

    for _, provider := range providers {
        switch p := provider.(type) {
        case fx.Option:
            app.options = append(app.options, p)
        default:
            app.options = append(app.options, fx.Provide(p))
        }
    }

    return app
}

func (a *Application) Run(invoker interface{}) {
    var args Arguments
    _, err := flags.Parse(&args)
    if err != nil {
        panic(err)
    }

    a.options = append(
        a.options,
        fx.Provide(func() context.Context {
            return a.ctx
        }),
        fx.Provide(func() Arguments {
            return args
        }),
        fx.Invoke(invoker),
        fx.Populate(&a.logger),
    )
    a.fxApp = fx.New(a.options...)

    go a.listenSignals()

    startCtx, cancel := context.WithTimeout(a.ctx, fx.DefaultTimeout)
    defer cancel()

    if err = a.fxApp.Start(startCtx); err != nil {
        if a.logger == nil {
            panic(err)
        }

        a.logger.Fatal(err)
    }
}

func (a *Application) Demonize(invoker interface{}) {
    a.Run(invoker)
    <-a.ctx.Done()
}

func (a *Application) Stop() {
    stopCtx, cancel := context.WithTimeout(a.ctx, fx.DefaultTimeout)
    defer cancel()
    err := a.fxApp.Stop(stopCtx)
    if err != nil {
        a.logger.Fatal(err)
    }
    a.fxApp = nil
}

func (a *Application) listenSignals() {
    signals := make(chan os.Signal, 1)
    signal.Notify(signals, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
    for sig := range signals {
        a.logger.Infof("income signal %s", sig)
        a.Stop()
        a.cancel()
        return
    }
}
		

Конструктор приложения:

  1. Принимает на входе конструкторы.
  2. Удовлетворяет зависимости сервисов между собой.
  3. Стартует как демон или воркер.

Теперь давайте рассмотрим файл с конструкторами pkg/user/infra/constructors.go:

			var Constructors = fx.Provide(
    usersrvimpl.NewFxUserService,
    usersrvimpl.NewFxUserProductService,
)
		

Как мы видим, файл с конструкторами имеет простой вид. Все конструкторы для fx имеют префикс NewFx. Все пакеты проекта должны содержать подобные файлы с конструкторами.

Перепишем cmd/user-server/main.go с учетом fx:

			app := application.New(
    commonadap.Constructors,
    productsadap.Constructors,
    useradap.Constructors,
    orderadap.Constructors,
    productapp.NewFxProductServiceServer,
)
app.Demonize(func(
    server grpc.Server,
    descriptor grpc.Descriptor,
) error {
    err := server.Register(descriptor)
    if err != nil {
        return err
    }

    err = server.Start()
    if err != nil {
        return err
    }

    return nil
})
		

Таким образом, код сильно упростился, соотв поддержка проекта будет дешевле.

Настройка проекта

После того как мы научились удовлетворять зависимости, необходимо научиться конфигурировать проект.

Для пакета конфигурирования проекта были следующие требования:

  1. Поддержка YAML, как наиболее человекочитаемого формата.
  2. Поддержка переменных окружения в стиле docker-compose.
  3. Возможность конфигурировать каждый пакет отдельно.

Нам на помощь приходит еще один пакет uber, а именно config.

Кроме описанных выше требований, пакет умеет:

  1. Объединять несколько конфигурационных файлов.
  2. Задавать значения переменных по умолчанию.

Напишем небольшой поставщик настроек pkg/common/adapter/application/provider.go:

			type Provider interface {
    Populate(target interface{}) error
    PopulateByKey(key string, target interface{}) error
}

func NewFxProvider(args Arguments) (Provider, error) {
    return NewProviderByOptions(config.File(args.ConfigFilename))
}

func NewProviderByOptions(options ...config.YAMLOption) (Provider, error) {
    dotenvFile, _ := godotenv.Read(".env")
    configProvider, err := config.NewYAML(append(
        []config.YAMLOption{
            config.Expand(expandFunc(dotenvFile)),
        },
        options...,
    )...)
    if err != nil {
        return nil, err
    }

    return &provider{configProvider}, nil
}

func expandFunc(extraEnv map[string]string) func(key string) (val string, ok bool) {
    return func(key string) (val string, ok bool) {
        val, ok = os.LookupEnv(key)
        if !ok {
            val, ok = extraEnv[key]
            if !ok {
                val = ""
                ok = true
            }
        }
        return val, ok
    }
}

type provider struct {
    provider config.Provider
}

func (p *provider) Populate(target interface{}) error {
    return p.PopulateByKey("", target)
}

func (p *provider) PopulateByKey(key string, target interface{}) error {
    return p.provider.Get(key).Populate(target)
}
		

Как понять, что код рабочий? Конечно, можно внедрить код в проект и проверить, как отрабатывает приложение, но это слишком дорогой путь, особенно в больших проектах. Чтобы проверить реализацию конкретного интерфейса, необходимо его протестировать. Для тестирования мы пишем unit-тесты с помощью пакета testify.

Напишем тестовый набор для поставщика настроек pkg/common/adapter/application/provider_test.go:

			type Config struct {
    A         A
    Partition int
}

type A struct {
    B string
    C struct {
        D bool
        F int
    }
}

func TestConfigProviderSuite(t *testing.T) {
    suite.Run(t, new(ConfigProviderSuite))
}

type ConfigProviderSuite struct {
    suite.Suite
}

func (s *ConfigProviderSuite) TestPopulate() {
    reader := strings.NewReader("a: {b: bar, c: {d: true, f: 12}}")

    provider, err := application.NewProviderByOptions(config.Source(reader))
    s.Nil(err)

    var a A
    s.Nil(provider.PopulateByKey("a", &a))
    s.Equal("bar", a.B)
    s.Equal(true, a.C.D)
    s.Equal(12, a.C.F)

    var f int
    s.Nil(provider.PopulateByKey("a.c.f", &f))
    s.Equal(12, f)

    var cfg Config
    s.Nil(provider.Populate(&cfg))
    s.Equal(cfg.A, a)
    s.Equal(cfg.A.B, a.B)
}

func (s *ConfigProviderSuite) TestExpand() {
    var a int
    var b string
    var c string
    varB := "hello world"
    err := os.Setenv("VAR_B", varB)
    if err != nil {
        s.Error(err)
    }

    reader := strings.NewReader(`
a: 1
b: "$VAR_B"
c: "$VAR_C"
`)

    provider, err := application.NewProviderByOptions(config.Source(reader))
    s.Nil(err)

    s.Nil(provider.PopulateByKey("a", &a))
    s.Equal(1, a)

    s.Nil(provider.PopulateByKey("b", &b))
    s.Equal(varB, b)

    s.Nil(provider.PopulateByKey("b", &b))
    s.Equal("", c)
}
		

Запустим в Goland тест с покрытием:

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 2

Или запустим тест в терминале:

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 3

Важно мерить покрытие кода тестами, т.к. тесты должны проверять все вхождения в условия и циклы. Если не тестировать код, то внедрение новой большой фичи или проведение крупного рефакторинга приведет к тому, что сломается половина функционала, отладка которого будет очень дорогой. Конечно, тесты необходимо поддерживать, но они же дают уверенность в том, что код рабочий.

Теперь мы можем интегрировать поставщика в проект, для этого добавим конструктор в fx pkg/common/adapter/constructors.go:

			var Constructors = fx.Provide(
    application.NewFxProvider,
    …
)
		

Добавим configs/config.yml в проект:

			server:
  http_port: 8080
  grpc_port: 8181
  max_send_message_length: 2147483647
  max_receive_message_length: 63554432
		

Прочитаем настройки в пакете pkg/common/adapter/server/grpc/server.go:

			func NewFxServer(
    in FxServerIn,
) (Server, error) {
    var cfg Config
    err := in.ConfigProvider.PopulateByKey("server", &cfg)
    if err != nil {
        return nil, err
    }
    …
}
		

Добавим конфигурационный файл в запуск приложения deployments/Dockerfile:

			CMD ${PROJECT_DIR}/bin/app -c ${PROJECT_DIR}/configs/config.yml
		

Теперь мы можем конфигурировать наше приложение, вычитывая в пакетах только ту информацию, которая необходима.

Middleware

Для каждого запроса, который принимает наше приложение необходимо выполнить последовательность одинаковых действий:

  1. Аутентифицировать пользователя.
  2. Проверить права пользователя перед выполнением метода API.

Как правило, такие задачи выносятся из бизнес-кода в специальный слой, который называется middleware. Middleware – это небольшая функция, которая:

  1. Перехватывает поток обработки запроса.
  2. Совершает какие-либо действия.
  3. Затем или прекращает выполнение запроса…
  4. …или передает запрос другой middleware или обработчику запроса.

К middleware предъявлены следующие требования:

  1. У нас монорепозиторий, в котором живут сразу несколько микросервисов, поэтому для каждого микросервиса может быть свой массив middleware.
  2. Middleware должны быть отсортированы в определенном порядке. Обрабатывая запрос, middleware наполняют контекст данными, которые могут смотреть другие middleware.

Опишем middleware в pkg/common/adapter/server/grpc/server.go:

			type Middleware struct {
    Priority   int
    GrpcOption grpc.UnaryServerInterceptor
    MuxOption  runtime.ServeMuxOption
}
		

Наши микросервисы используют и grpc и grpc-gateway, поэтому middleware содержит опции для обоих пакетов.

Для того, чтобы гибко собирать middleware массив, воспользуемся группировкой зависимостей fx:

			type MiddlewareOut struct {
    fx.Out
    GrpcMiddleware Middleware `group:"grpc_middleware"`
    MuxMiddleware  Middleware `group:"mux_middleware"`
}
		

Данная структура говорит fx, что инициализированные свойства MiddlewareOut необходимо положить в массивы:

			type FxServerIn struct {
    fx.In
    …
    GrpcMiddlewares []Middleware `group:"grpc_middleware"`
    MuxMiddlewares  []Middleware `group:"mux_middleware"`
}
		

Задачу сортировки middleware решим с помощью пакета sort:

			type ByPriority []Middleware

func (b ByPriority) Len() int {
    return len(b)
}

func (b ByPriority) Swap(i, j int) {
    b[i], b[j] = b[j], b[i]
}

func (b ByPriority) Less(i, j int) bool {
    return b[i].Priority > b[j].Priority
}
		

Объявим middleware в нашем сервере:

			func NewFxServer(
    in FxServerIn,
) (Server, error) {
    …

    sort.Sort(ByPriority(in.MuxMiddlewares))
    sort.Sort(ByPriority(in.GrpcMiddlewares))

    interceptors := make([]grpc.UnaryServerInterceptor, len(in.GrpcMiddlewares))
    for i, middleware := range in.GrpcMiddlewares {
        interceptors[i] = middleware.GrpcOption
    }

    serverMuxOptions := make([]runtime.ServeMuxOption, len(in.MuxMiddlewares))
    for i, middleware := range in.MuxMiddlewares {
        serverMuxOptions[i] = middleware.MuxOption
    }

    srv := &server{
        …
        grpcServer: grpc.NewServer(
            grpc.MaxRecvMsgSize(cfg.MaxReceiveMessageLength),
            grpc.MaxSendMsgSize(cfg.MaxSendMessageLength),
            grpc.ChainUnaryInterceptor(interceptors...),
        ),
        mux: runtime.NewServeMux(
            serverMuxOptions...,
        ),
        …
    }

    return srv, nil
}
		

Итак, мы организовали механизм гибкой инициализации middleware, теперь давайте наполним его полезным функционалом.

Аутентификация

Наше приложение принимает множество запросов. Чтобы понять от какого пользователя идут запросы, необходимо аутентифицировать пользователя. В качестве механизма аутентификации мы используем JWT. Для создания и валидации JWT необходимы приватный и публичный ключ, создадим их:

			openssl req -newkey rsa -x509 -sha256 -days 3650 -nodes -out ./configs/ssl/crt.pem -keyout ./configs/ssl/private.key.pem
openssl rsa -in ./configs/ssl/private.key.pem -outform PEM -pubout -out ./configs/ssl/public.key.pem
		

Добавим поддержку в JWT-токена в swagger, для этого дополним api/proto/v1/header.proto:

			option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
    …
    security_definitions: {
        security: {
            key: "Bearer"
            value: {
                type: TYPE_API_KEY;
                in: IN_HEADER;
                name: "Authorization"
            }
        };
    };
    …
};
		

Опишем сессию, которую будем хранить в JWT в pkg/common/adapter/auth/claims.go:

			type SessionClaims struct {
    jwt.StandardClaims
    pbv1.Session
}

func (c SessionClaims) Valid() error {
    err := c.StandardClaims.Valid()
    if err != nil {
        return err
    }

    if len(c.Uuid) == 0 {
        return errors.New("uuid is invalid")
    }

    if _, ok := pbv1.UserGroup_name[int32(c.Group)]; !ok {
        return errors.New("group is invalid")
    }

    return nil
}
		

Кроме стандартных проверок на подпись и время жизни, мы валидируем содержимое токена, необходимое нашему приложению. Самое время реализовать адаптер pkg/common/adapter/auth/jwt_helper.go:

			type Claims interface {
    jwt.Claims
}

type JWTHelper interface {
    Parse(token string, claims Claims) error
    CreateToken(claims Claims) (string, error)
}

func NewFxJWTHelper(
    provider application.Provider,
) (JWTHelper, error) {
    var cfg SslConfig
    err := provider.PopulateByKey("ssl", &cfg)
    if err != nil {
        return nil, err
    }

    buf, err := ioutil.ReadFile(cfg.PrivateKeyFile)
    if err != nil {
        return nil, err
    }

    privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(buf)
    if err != nil {
        return nil, err
    }

    buf, err = ioutil.ReadFile(cfg.PublicKeyFile)
    if err != nil {
        return nil, err
    }

    publicKey, err := jwt.ParseRSAPublicKeyFromPEM(buf)
    if err != nil {
        return nil, err
    }

    return &jwtHelper{
        publicKey:  publicKey,
        privateKey: privateKey,
    }, nil
}

type jwtHelper struct {
    publicKey  *rsa.PublicKey
    privateKey *rsa.PrivateKey
}

func (h *jwtHelper) Parse(token string, claims Claims) error {
    _, err := jwt.ParseWithClaims(token, claims, func(token *jwt.Token) (interface{}, error) {
        return h.publicKey, nil
    })
    if err != nil {
        return err
    }

    return claims.Valid()
}

func (h *jwtHelper) CreateToken(claims Claims) (string, error) {
    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
    return token.SignedString(h.privateKey)
}
		

Затем напишем тест на адаптер pkg/common/adapter/auth/jwt_helper_test.go.

Создадим менеджера сессий pkg/common/adapter/auth/session_manager.go:

			func NewFxSessionManager(
    provider application.Provider,
    logger log.Logger,
    jwtHelper JWTHelper,
) (SessionManager, error) {
    var cfg SessionConfig
    err := provider.PopulateByKey("session", &cfg)
    if err != nil {
        return nil, err
    }

    return &sessionManager{
        logger:    logger.Named("session_manager"),
        cfg:       cfg,
        jwtHelper: jwtHelper,
    }, nil
}

type sessionManager struct {
    logger    log.Logger
    cfg       SessionConfig
    jwtHelper JWTHelper
}

func (s *sessionManager) CreateTokenBySession(ctx context.Context, session pbv1.Session) (string, error) {
    claims := &SessionClaims{
        StandardClaims: jwt.StandardClaims{
            Audience:  s.cfg.Audience,
            Issuer:    s.cfg.Issuer,
            IssuedAt:  time.Now().Unix(),
            ExpiresAt: time.Now().Add(s.cfg.Duration).Unix(),
        },
        Session: session,
    }

    return s.jwtHelper.CreateToken(claims)
}

func (s *sessionManager) CreateToken(ctx context.Context, userUUID string, group pbv1.UserGroup) (string, error) {
    return s.CreateTokenBySession(
        ctx,
        pbv1.Session{
            Uuid:  userUUID,
            Group: group,
        },
    )
}

func (s *sessionManager) GetSessionByToken(ctx context.Context, token string) (pbv1.Session, error) {
    logger := s.logger.WithCtx(ctx)
    if len(token) == 0 {
        logger.Debug("guest session")
        return pbv1.Session{
            Group: pbv1.UserGroupGuest,
        }, nil
    }

    claims := new(SessionClaims)
    err := s.jwtHelper.Parse(token, claims)
    if err != nil {
        logger.Error(err)
        return pbv1.Session{}, grpc.ErrUnauthenticated(grpc.ErrSessionNotFound)
    }

    logger.Debugf("b2c session=%s with group=%s", claims.Session.Uuid, claims.Session.Group)
    return claims.Session, nil
}
		

Добавим middleware pkg/common/adapter/server/grpc/grpc_option/auth.go:

			func NewFxAuthOption(
    logger log.Logger,
    sessionManager auth.SessionManager,
) grpc.MiddlewareOut {
    return grpc.MiddlewareOut{
        GrpcMiddleware: grpc.Middleware{
            Priority: 99,
            GrpcOption: func(ctx context.Context, req interface{}, info *gRPC.UnaryServerInfo, handler gRPC.UnaryHandler) (resp interface{}, err error) {
                logger := logger.WithCtx(ctx, "middleware", "auth")
                token, err := grpc_auth.AuthFromMD(ctx, "bearer")
                if err != nil {
                    logger.Error(err)
                }

                session, err := sessionManager.GetSessionByToken(ctx, token)
                if err != nil {
                    logger.Error(err)
                    return nil, err
                }

                return handler(ctxutil.Set(ctx, ctxutil.Session, session), req)
            },
        },
    }
}
		

Middleware пытается:

  1. Получить JWT из заголовка Authorization.
  2. Если это не удается, создается гостевая сессия и кладется в контекст.
  3. Если удается, и токен невалидный API, то выполнение запроса прерывается.
  4. Если токен валидный, то пользовательская сессия кладется в контекст.

Аутентификация пользователя готова, теперь необходимо понять, может ли пользователь выполнять запрос к конкретному методу API.

RBAC

Наше приложение  может быть доступно нескольким группам пользователей:

  1. Неавторизованным пользователям, т.е. гостям
  2. Покупателям
  3. Сотрудникам магазина

У каждой группы пользователей могут быть свои права на выполнение методов API, например:

  1. Гости могут авторизовываться, просматривать товары, но не могут добавлять товар в корзину и осуществлять покупку.
  2. Покупатели могут просматривать товары, добавлять товар в корзину и осуществлять покупку, но не могут управлять пользователями, товарами и покупками.
  3. Сотрудник может управлять пользователями, товарами и покупками, но не может осуществлять покупки.

Очевидно, что для разграничения доступов к API требуется ролевая модель доступов (RBAC). Ролевую модель можем завязать на группы пользователей, которые мы описали ранее в api/proto/v1/user.proto:

			// субъект в терминах RBAC
enum UserGroup {
    USER_STATUS_GUEST = 0 [(go.value) = {
        name: "UserGroupGuest"
    }];
    USER_STATUS_CUSTOMER = 1 [(go.value) = {
        name: "UserGroupCustomer"
    }];
}
		

Добавим в этот файл роли и операции:

			// роль в терминах RBAC
enum Role {
    ROLE_UNSPECIFIED = 0 [(go.value) = {
        name: "RoleUnspecified"
    }];
    ROLE_USER = 1 [(go.value) = {
        name: "RoleUser"
    }];
    ROLE_PRODUCT = 2 [(go.value) = {
        name: "RoleProduct"
    }];
    ROLE_ORDER = 3 [(go.value) = {
        name: "RoleOrder"
    }];
    ROLE_USER_PRODUCT = 4 [(go.value) = {
        name: "RoleUserProduct"
    }];
}

// разрешение в терминах RBAC
enum Permission {
    PERMISSION_UNSPECIFIED = 0 [(go.value) = {
        name: "PermissionUnspecified"
    }];
    PERMISSION_READ = 1 [(go.value) = {
        name: "PermissionRead"
    }];
    PERMISSION_WRITE = 2 [(go.value) = {
        name: "PermissionWrite"
    }];
}
		

Далее необходимо найти пакет, который умеет работать с RBAC. Самым популярным решением является casbin, но его требуется вписать в общую архитектуру проекта. Для этого пишем небольшой адаптер pkg/common/adapter/auth/role_enforcer.go:

			type RoleEnforcer interface {
    Enforce(session pbv1.Session, role pbv1.Role, permission pbv1.Permission) (bool, error)
}

type roleEnforcer struct {
    enforcer *casbin.Enforcer
    logger   log.Logger
}

func NewFxRoleEnforcer(
    logger log.Logger,
    configProvider application.Provider,
) (RoleEnforcer, error) {
    var cfg Config
    err := configProvider.PopulateByKey("enforcer", &cfg)
    if err != nil {
        return nil, err
    }

    casbinEnforcer, err := casbin.NewEnforcer(cfg.ModelFile, cfg.PolicyFile)
    if err != nil {
        return nil, err
    }

    return &roleEnforcer{
        enforcer: casbinEnforcer,
        logger:   logger.Named("role_enforcer"),
    }, nil
}

func (e *roleEnforcer) Enforce(session pbv1.Session, role pbv1.Role, permission pbv1.Permission) (bool, error) {
    return e.enforcer.Enforce(                              strings.ReplaceAll(pbv1.UserGroup_name[int32(session.Group)], "USER_GROUP_", ""),
        pbv1.Role_name[int32(role)],
        pbv1.Permission_name[int32(permission)],
    )
}
		

Объявим модель configs/enforcer/model.conf:

			[request_definition]
r = group, role, act

[policy_definition]
p = group, role, act

[role_definition]
g = _, _

[policy_effect]
e = some(where (p.eft == allow))

[matchers]
m = g(r.group, p.group) \
    && r.role == p.role \
    && r.act == p.act
		

Объявим политики configs/enforcer/policy.csv:

			p, USER_GROUP_GUEST, ROLE_USER, PERMISSION_WRITE
p, USER_GROUP_GUEST, ROLE_PRODUCT, PERMISSION_READ

p, USER_GROUP_CUSTOMER, ROLE_USER, PERMISSION_READ
p, USER_GROUP_CUSTOMER, ROLE_ORDER, PERMISSION_READ
p, USER_GROUP_CUSTOMER, ROLE_ORDER, PERMISSION_WRITE
p, USER_GROUP_CUSTOMER, ROLE_USER_PRODUCT, PERMISSION_READ
p, USER_GROUP_CUSTOMER, ROLE_USER_PRODUCT, PERMISSION_WRITE

g, GUEST, USER_GROUP_GUEST
g, CUSTOMER, USER_GROUP_GUEST
g, CUSTOMER, USER_GROUP_CUSTOMER
		

Только что мы сказали casbin что:

  1. Гость имеет права:
    a. Запись пользователя.
    b. Чтение товаров.
  2. Покупатель имеет те же права, что и гость.
  3. Покупатель имеет права:
    a. Чтение пользователя.
    b. Чтение и запись товаров в корзине.
    c. Чтение и запись заказа.

Протестируем код в pkg/common/adapter/auth/role_enforcer.go.

Добавим описание прав для методов API:

			func NewFxUserServiceServer(
    userService usersrv.UserService,
    userProductService usersrv.UserProductService,
) grpc.Descriptor {
    return grpc.Descriptor{
        Server: &server{
            userService:        userService,
            userProductService: userProductService,
        },
        GRPCRegistrar:        pbv1.RegisterUserServiceServer,
        GRPCGatewayRegistrar: pbv1.RegisterUserServiceHandlerFromEndpoint,
        MethodDescriptors: []grpc.MethodDescriptor{
            {
                Method:     (*server).Register,
                Role:       pbv1.RoleUser,
                Permission: pbv1.PermissionRead,
            },
            {
                Method:     (*server).GetByUUID,
                Role:       pbv1.RoleUser,
                Permission: pbv1.PermissionRead,
            },
            …
        },
    }
}
		

Владение сущностью

Предоставление прав на выполнение тех или иных методов API хорошо, но этого не достаточно, т.к. авторизованный злоумышленник может попытаться получить информацию о другом пользователе. Нам необходимо проверять что переданный JWT-токен может получать или изменять указанные данные. Для этого создадим интерфейс и описание к нему:

			type RightsEnforcer interface {
    Enforce(ctx context.Context, session pbv1.Session, value protoreflect.Value) (context.Context, error)
}

type RightsEnforcerDescriptorOut struct {
    fx.Out
    Descriptor RightsEnforcerDescriptor `group:"rights_enforcer"`
}

type RightsEnforcerDescriptor struct {
    Name           string
    RightsEnforcer RightsEnforcer
}
		

Реализация интерфейса должна регистрироваться в fx по переменной из url-пути и осуществлять какие-либо проверки. Давайте рассмотрим пример.

У нас есть GET-запрос /v1/users/{user_uuid}, в котором используется переменная user_uuid. Напишем и зарегистрируем по user_uuid реализацию RightsEnforcer для пользователя в pkg/user/infra/middleware/user_rights_enforcer.go:

			func NewFxUserRightsEnforcer(
    userService usersrv.UserService,
) auth.RightsEnforcerDescriptorOut {
    return auth.RightsEnforcerDescriptorOut{
        Descriptor: auth.RightsEnforcerDescriptor{
            Name:           "user_uuid",
            RightsEnforcer: NewUserRightsEnforcer(userService),
        },
    }
}

func NewUserRightsEnforcer(
    userService usersrv.UserService,
) auth.RightsEnforcer {
    return &userRightsEnforcer{
        userService: userService,
    }
}

type userRightsEnforcer struct {
    userService usersrv.UserService
}

func (r userRightsEnforcer) Enforce(ctx context.Context, session pbv1.Session, value protoreflect.Value) (context.Context, error) {
    user, err := r.userService.GetByUUID(ctx, value.String())
    if err != nil {
        return nil, err
    }

    if user.Status != pbv1.UserStatusActive {
        return nil, grpc.ErrSessionHasNotPermissions
    }

    if session.Uuid != user.Uuid {
        return nil, grpc.ErrSessionNotOwnEntity
    }

    return ctxutil.Set(ctx, ctxutil.User, user), nil
}
		

Данная реализация RightsEnforcer сравнивает идентификаторы пользователя из url-пути и JWT-токена. Если идентификаторы не совпадают, тогда выполнение запроса прерывается. Добавим реализацию RightsEnforcer для:

  1. Товаров в корзине pkg/user/infra/middleware/user_product_right_enforcer.go.
  2. Заказа pkg/order/infra/middleware/order_rights_enforcer.go.

Теперь мы готовы проверять права в middleware pkg/common/adapter/server/grpc/grpc_option/enforcer.go:

			type EnforcerOptionIn struct {
    fx.In
    Logger                    log.Logger
    RoleEnforcer              auth.RoleEnforcer
    MethodDescriptorMap       grpc.MethodDescriptorMap
    RightsEnforcerDescriptors []auth.RightsEnforcerDescriptor `group:"rights_enforcer"`
}

func NewFxEnforcerOption(in EnforcerOptionIn) grpc.MiddlewareOut {
    rightsEnforcers := protoutil.NewMap[auth.RightsEnforcer]()
    for _, descriptor := range in.RightsEnforcerDescriptors {
        rightsEnforcers.Set(descriptor.Name, descriptor.RightsEnforcer)
    }

    return grpc.MiddlewareOut{
        GrpcMiddleware: grpc.Middleware{
            Priority: 98,
            GrpcOption: func(ctx context.Context, req interface{}, info *gRPC.UnaryServerInfo, handler gRPC.UnaryHandler) (resp interface{}, err error) {
                logger := in.Logger.WithCtx(ctx, "middleware", "enforcer")
                methodNameParts := strings.Split(info.FullMethod, "/")
                methodName := methodNameParts[len(methodNameParts)-1]
                methodDescriptor, ok := in.MethodDescriptorMap[methodName]
                if !ok {
                    return nil, grpc.ErrUnauthenticated(grpc.ErrMethodDescriptorNotFound)
                }

                session, err := ctxutil.Get[pbv1.Session](ctx, ctxutil.Session)
                if err != nil {
                    return nil, grpc.ErrPermissionDenied(grpc.ErrSessionNotFound)
                }

                ok, err = in.RoleEnforcer.Enforce(session, methodDescriptor.Role, methodDescriptor.Permission)
                if !ok {
                    logger.Error(err)
                    return nil, grpc.ErrPermissionDenied(grpc.ErrSessionHasNotPermissions)
                }

                protoMessage, ok := req.(protoreflect.ProtoMessage)
                if ok {
                    message := protoMessage.ProtoReflect()
                    fields := message.Descriptor().Fields()
                    for i := 0; i < fields.Len(); i++ {
                        field := fields.Get(i)
                        rightsEnforcer, err := rightsEnforcers.Get(message, field)
                        if err != nil {
                            logger.Error(err)
                            continue
                        }

                        ctx, err = rightsEnforcer.Enforce(ctx, session, message.Get(field))
                        if err != nil {
                            logger.Error(err)
                            return nil, grpc.ErrPermissionDenied(grpc.ErrSessionNotOwnEntity)
                        }
                    }
                }

                return handler(ctx, req)
            },
        },
    }
}
		

HTTPS

Мы с вами защитили наше API от несанкционированного доступа к данным. Теперь давайте зашифруем данные запросов, добавив поддержку HTTPS.

Дополняем api/proto/v1/header.proto:

			option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
    …
    schemes: HTTPS;
    …
};
		

Дополняем описания методов сервисов, для примера возьмем api/proto/v1/user_service.proto:

			service UserService {
    …
    rpc GetByUUID(GetByUserUUIDRequest) returns (User) {
        option (google.api.http) = {
            get: "/v1/users/{user_uuid}";
        };
        option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
            summary: "Получение пользователя по UUID";
            security: {
                security_requirement: {
                    key: "Bearer"
                    value: {}
                }
            };
        };
    };
    …
}
		

Включим поддержку https в deployments/nginx.conf

			server {
    listen 443 http2 ssl;
    …
    ssl_certificate     /etc/nginx/ssl/crt.pem;
    ssl_certificate_key /etc/nginx/ssl/private.key.pem;
    …
}
		

Тестирование

Доработки кода приложения и инфраструктуры завершены, самое время протестировать наше приложение. Поднимаем приложение:

			make up
		

Открываем https://enterprise.application.local

В системе уже есть зарегистрированный пользователь 387301f4-551c-4022-900a-80f6f76f3a10.

Давайте попробуем получить данные пользователя.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 4

Получили ошибку, гость не может получать данные пользователя.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 5

Авторизуемся пользователем.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 6
Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 7
Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 8
Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 9

Выполняем запрос еще раз и получаем успешный ответ с данными пользователя.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 10
Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 11

Теперь зарегистрируем нового пользователя.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 12

В ответ получим JWT-токен, с помощью которого необходимо заново авторизоваться. После авторизации пробуем получить информацию по пользователю.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 13

Получаем успешный ответ с данными о пользователе.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 14

Давайте попробуем получить информацию об пользователе 387301f4-551c-4022-900a-80f6f76f3a10.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 15

На этот раз мы получили ошибку о том, что не можем читать чужие данные.

Как разработать веб-приложение уровня Enterprise Application с нуля. Часть 2 16

Как мы видим, приложения отработали штатно, предоставив выполнение только к разрешенным методам API и только к своим данным. 

Итоги

Итак, в этой статье мы научились:

  1. Конфигурировать приложение.
  2. Использовать внедрение зависимостей.
  3. Авторизовывать пользователей через JWT-токен.
  4. Организовывать проверку прав на основе RBAC и дополнительных проверок.

Как и в предыдущей статье, код представлен в репозитории.

В следующей статье мы обсудим следующие темы:

  1. Валидация запросов.
  2. Выбор драйвера СУБД.
  3. Репозиторный слой приложения.
  4. Моки в тестах.
Следите за новыми постами
Следите за новыми постами по любимым темам
2К открытий2К показов