План
- Вводная
- Ветвление репозитория
- Внедрение зависимостей
- Настройка проекта
- Middleware:
Аутентификация
RBAC
Владение сущностью - HTTPS
- Тестирование
- Итоги
Вводная
Всем привет, сегодня мы продолжаем серию статей о том, как разработать коммерческое веб-приложение с нуля. Код представлен в репозитории.
В предыдущей статье мы поговорили о том, как:
- Собрать требования.
- Разработать архитектуру.
- Выбрать веб-фреймворк.
- Задокументировать API.
Очевидно, что разработанный проект еще очень далек от того, чтобы с ним могли работать пользователи:
- В жизненном цикле приложений проекта возникают случаи, когда какие то параметры необходимо изменить.
- В зависимости от состояния, пользователю доступны разные методы API
- Пользователь может получать или изменять только те данные, которыми владеет.
Но прежде чем мы решим описанные проблемы, необходимо понять:
- Как поддерживать несколько версий проекта.
- Как без труда удовлетворять зависимости одних сервисов от других.
Ветвление репозитория
На изображении представлен реальный жизненный цикл проекта:
- В master работает версия v1.0 на production-серверах.
- В версии v1.0 нашли баги и подготовили hotfixes.
- Hotfixes влили в master и выложили на production-серверы, получив версию проекта v1.1.
- Одновременно с master живет новая версия проекта в develop на develop-серверах.
- В нее тоже необходимо донести hotfixes с master.
- Кроме того, разработчики заливают в develop новые задачи из веток features.
- Затем в какой то момент делается срез develop в ветку release.
- Release-ветка стабилизируется, проходит регресс-тестирование и выкладывается на production-сервера.
Данный жизненный цикл прекрасно ложится в стратегию ветвления GitFlow.
Использую эту стратегию в своем проекте мы:
- Наводим порядок в именовании веток репозитория:
a. feature/идентификатор_задачи_в_трекере.
b. hotfix/идентификатор_задачи_в_трекере.
c. release/Х.Х.Х. - Завязываем сборки CI/CD на стандартизированные префиксы веток.
- Делаем независимые друг от друга сборки фичей, фиксов и релизов .
- Получаем ситуацию, в которой поддержка текущей версии не мешает разработке новой версии проекта.
Команда проекта 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 # будем разрабатывать вторую статью в отдельной ветке
Таким образом получили результат:
- Читатели первой статьи получают рабочий пример в ветке release/1.0.0.
- Функционал второй статьи будет разрабатываться в ветке feature/article-2.
- Соотв никто никому не мешает.
Внедрение зависимостей
Как уже упоминалось в предыдущей статье:
- Архитектура разбита на домены
- Логика домена реализована в сервисах.
- Сервисы зависят от других сервисов.
Давайте рассмотрим 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)
}
Этот код плох тем, что мы вручную удовлетворяем зависимости конструкторов:
- usersrvimpl.NewUserProductService
- userapp.NewServer
Возможно, сейчас проблема не кажется такой серьезной, но в реальном проекте у одного сервиса может быть десяток зависимостей, которые, в свою очередь, могут иметь свои зависимости. В итоге реальный проект имеет большой граф зависимостей, который сложно удовлетворить вручную.
Для решения проблемы существуют популярные пакеты удовлетворяющие зависимости:
- uber fx + uber dig.
- 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
}
}
Конструктор приложения:
- Принимает на входе конструкторы.
- Удовлетворяет зависимости сервисов между собой.
- Стартует как демон или воркер.
Теперь давайте рассмотрим файл с конструкторами 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
})
Таким образом, код сильно упростился, соотв поддержка проекта будет дешевле.
Настройка проекта
После того как мы научились удовлетворять зависимости, необходимо научиться конфигурировать проект.
Для пакета конфигурирования проекта были следующие требования:
- Поддержка YAML, как наиболее человекочитаемого формата.
- Поддержка переменных окружения в стиле docker-compose.
- Возможность конфигурировать каждый пакет отдельно.
Нам на помощь приходит еще один пакет uber, а именно config.
Кроме описанных выше требований, пакет умеет:
- Объединять несколько конфигурационных файлов.
- Задавать значения переменных по умолчанию.
Напишем небольшой поставщик настроек 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 тест с покрытием:
Или запустим тест в терминале:
Важно мерить покрытие кода тестами, т.к. тесты должны проверять все вхождения в условия и циклы. Если не тестировать код, то внедрение новой большой фичи или проведение крупного рефакторинга приведет к тому, что сломается половина функционала, отладка которого будет очень дорогой. Конечно, тесты необходимо поддерживать, но они же дают уверенность в том, что код рабочий.
Теперь мы можем интегрировать поставщика в проект, для этого добавим конструктор в 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
Для каждого запроса, который принимает наше приложение необходимо выполнить последовательность одинаковых действий:
- Аутентифицировать пользователя.
- Проверить права пользователя перед выполнением метода API.
Как правило, такие задачи выносятся из бизнес-кода в специальный слой, который называется middleware. Middleware — это небольшая функция, которая:
- Перехватывает поток обработки запроса.
- Совершает какие-либо действия.
- Затем или прекращает выполнение запроса…
- …или передает запрос другой middleware или обработчику запроса.
К middleware предъявлены следующие требования:
- У нас монорепозиторий, в котором живут сразу несколько микросервисов, поэтому для каждого микросервиса может быть свой массив middleware.
- 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 пытается:
- Получить JWT из заголовка Authorization.
- Если это не удается, создается гостевая сессия и кладется в контекст.
- Если удается, и токен невалидный API, то выполнение запроса прерывается.
- Если токен валидный, то пользовательская сессия кладется в контекст.
Аутентификация пользователя готова, теперь необходимо понять, может ли пользователь выполнять запрос к конкретному методу API.
RBAC
Наше приложение может быть доступно нескольким группам пользователей:
- Неавторизованным пользователям, т.е. гостям
- Покупателям
- Сотрудникам магазина
У каждой группы пользователей могут быть свои права на выполнение методов API, например:
- Гости могут авторизовываться, просматривать товары, но не могут добавлять товар в корзину и осуществлять покупку.
- Покупатели могут просматривать товары, добавлять товар в корзину и осуществлять покупку, но не могут управлять пользователями, товарами и покупками.
- Сотрудник может управлять пользователями, товарами и покупками, но не может осуществлять покупки.
Очевидно, что для разграничения доступов к 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 что:
- Гость имеет права:
a. Запись пользователя.
b. Чтение товаров. - Покупатель имеет те же права, что и гость.
- Покупатель имеет права:
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 для:
- Товаров в корзине pkg/user/infra/middleware/user_product_right_enforcer.go.
- Заказа 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.
Давайте попробуем получить данные пользователя.
Получили ошибку, гость не может получать данные пользователя.
Авторизуемся пользователем.
Выполняем запрос еще раз и получаем успешный ответ с данными пользователя.
Теперь зарегистрируем нового пользователя.
В ответ получим JWT-токен, с помощью которого необходимо заново авторизоваться. После авторизации пробуем получить информацию по пользователю.
Получаем успешный ответ с данными о пользователе.
Давайте попробуем получить информацию об пользователе 387301f4-551c-4022-900a-80f6f76f3a10.
На этот раз мы получили ошибку о том, что не можем читать чужие данные.
Как мы видим, приложения отработали штатно, предоставив выполнение только к разрешенным методам API и только к своим данным.
Итоги
Итак, в этой статье мы научились:
- Конфигурировать приложение.
- Использовать внедрение зависимостей.
- Авторизовывать пользователей через JWT-токен.
- Организовывать проверку прав на основе RBAC и дополнительных проверок.
Как и в предыдущей статье, код представлен в репозитории.
В следующей статье мы обсудим следующие темы:
- Валидация запросов.
- Выбор драйвера СУБД.
- Репозиторный слой приложения.
- Моки в тестах.