Skip to content

EVEREST-1925 Blocklisting mechanism #1293

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
75c0322
EVEREST-1925 WIP blocklist
oksana-grishchenko Apr 7, 2025
1d66a76
changes in cleanup, renamings
oksana-grishchenko Apr 7, 2025
f6be2c3
tests, improvements
oksana-grishchenko Apr 8, 2025
e517798
Merge branch 'main' into EVEREST-1925-blacklisting-mechanism
oksana-grishchenko Apr 8, 2025
40392b7
format tests
oksana-grishchenko Apr 8, 2025
0c172a0
remove using github.com/pkg/errors
oksana-grishchenko Apr 8, 2025
cc30ee0
fix tests
oksana-grishchenko Apr 8, 2025
e6fb101
remove informer link
oksana-grishchenko Apr 8, 2025
e20daf6
Merge branch 'main' into EVEREST-1925-blacklisting-mechanism
oksana-grishchenko Apr 8, 2025
316e230
format, add comments, refactor
oksana-grishchenko Apr 8, 2025
74d528d
Merge remote-tracking branch 'origin/EVEREST-1925-blacklisting-mechan…
oksana-grishchenko Apr 8, 2025
ba3b7f4
new tests, refactoring
oksana-grishchenko Apr 8, 2025
a24c580
format, update tests
oksana-grishchenko Apr 8, 2025
0cdd385
Merge branch 'main' into EVEREST-1925-blacklisting-mechanism
oksana-grishchenko Apr 8, 2025
21d129a
Merge branch 'main' into EVEREST-1925-blacklisting-mechanism
oksana-grishchenko Apr 18, 2025
969e117
use cache for everest API server
oksana-grishchenko Apr 21, 2025
02dbbd5
remove cached secret, use token store
oksana-grishchenko Apr 21, 2025
d7eb851
use RWMutex
oksana-grishchenko Apr 22, 2025
246833a
Update pkg/kubernetes/kubernetes.go
oksana-grishchenko Apr 23, 2025
23019cd
Update pkg/kubernetes/kubernetes.go
oksana-grishchenko Apr 23, 2025
cea26e1
address review comments
oksana-grishchenko Apr 23, 2025
fb874f5
add tests
oksana-grishchenko Apr 23, 2025
749b2a3
address review comments
oksana-grishchenko Apr 24, 2025
bea23f4
do not use controller-runtime cache
oksana-grishchenko Apr 24, 2025
1701b6f
Merge remote-tracking branch 'origin/EVEREST-1925-blacklisting-mechan…
oksana-grishchenko Apr 24, 2025
f3bb4e3
fix merge conflicts
oksana-grishchenko Apr 24, 2025
ded35e6
upd error message
oksana-grishchenko Apr 24, 2025
3d0f4ee
use restricted controller-runtime cache
oksana-grishchenko Apr 24, 2025
1485c76
Update pkg/session/token_store.go
oksana-grishchenko Apr 25, 2025
05b7183
Merge remote-tracking branch 'origin/EVEREST-1925-blacklisting-mechan…
oksana-grishchenko Apr 25, 2025
5fb3c55
Update pkg/session/token_store.go
oksana-grishchenko Apr 25, 2025
62d61e1
Merge remote-tracking branch 'origin/EVEREST-1925-blacklisting-mechan…
oksana-grishchenko Apr 25, 2025
9ba1ba9
use separate controller-runtime cache & address comments
oksana-grishchenko Apr 25, 2025
d226873
Merge branch 'EVEREST-1923-new-logout-flow' into EVEREST-1925-blackli…
oksana-grishchenko Apr 25, 2025
888a264
Update pkg/session/token_store.go
oksana-grishchenko Apr 28, 2025
508fcb7
Update pkg/session/token_store.go
oksana-grishchenko Apr 28, 2025
befc978
Update internal/server/everest.go
oksana-grishchenko Apr 28, 2025
1e46399
make blocklist dependent from tokenstore
oksana-grishchenko Apr 28, 2025
d49c4f9
Merge remote-tracking branch 'origin/EVEREST-1925-blacklisting-mechan…
oksana-grishchenko Apr 28, 2025
3e4ca6f
update error message
oksana-grishchenko Apr 28, 2025
dfac7bb
move middleware func inside session manager
oksana-grishchenko Apr 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/dev-be-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,10 @@ jobs:
run: |
kubectl port-forward --namespace everest-system deployment/everest-server 8080:8080 &

- name: Create Everest test user
- name: Create Everest test users
run: |
./bin/everestctl accounts create -u test -p password
echo "API_TOKEN_TEST=$(curl --location -s 'localhost:8080/v1/session' --header 'Content-Type: application/json' --data '{"username": "test","password": "password"}' | jq -r .token)" >> $GITHUB_ENV
./bin/everestctl accounts create -u everest_ci -p password
echo "API_TOKEN=$(curl --location -s 'localhost:8080/v1/session' --header 'Content-Type: application/json' --data '{"username": "everest_ci","password": "password"}' | jq -r .token)" >> $GITHUB_ENV

Expand Down
22 changes: 22 additions & 0 deletions api-tests/tests/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,25 @@ test.describe('no authorization header', () => {
expect(version.status()).toEqual(400);
});
});

test.describe('logout', () => {
test.use({
extraHTTPHeaders: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${process.env.API_TOKEN_TEST}`,
},
});

test('authenticated api request fails after logout', async ({request}) => {
const versionBeforeLogout = await request.get('/v1/version');
// NOTE: this test is сontext-dependent, the API_TOKEN_TEST needs to be valid before each run
expect(versionBeforeLogout.status()).toEqual(200);

const response = await request.delete('/v1/session');
expect(response.status()).toEqual(204);

const versionAfterLogout = await request.get('/v1/version');
expect(versionAfterLogout.status()).toEqual(401);
});
});
310 changes: 163 additions & 147 deletions api/everest-server.gen.go

Large diffs are not rendered by default.

413 changes: 266 additions & 147 deletions client/everest-client.gen.go

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions docs/spec/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
'429':
description: Too many attempts
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error
content:
Expand All @@ -72,6 +78,28 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/UserCredentials'
delete:
tags:
- Authentication & Authorization
summary: Everest UI Logout
description: |
This API invalidates Everest API JWT token.
operationId: deleteSession
responses:
'204':
description: Successful operation
'429':
description: Too many attempts
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
'/permissions':
get:
tags:
Expand Down
39 changes: 38 additions & 1 deletion internal/server/everest.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"net/http"
"slices"

"github.com/AlekSi/pointer"
"github.com/getkin/kin-openapi/openapi3filter"
"github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4"
Expand Down Expand Up @@ -61,6 +62,7 @@ type EverestServer struct {
sessionMgr *session.Manager
attemptsStore *RateLimiterMemoryStore
handler handlers.Handler
blocklist session.Blocklist
oidcProvider *oidc.ProviderConfig
}

Expand Down Expand Up @@ -110,13 +112,19 @@ func NewEverestServer(ctx context.Context, c *config.EverestConfig, l *zap.Sugar
return nil, errors.Join(err, errors.New("failed to get OIDC provider config"))
}

blockList, err := session.NewBlocklist(ctx, kubeClient, l)
if err != nil {
return nil, errors.Join(err, errors.New("failed to configure tokens blocklist"))
}

e := &EverestServer{
config: c,
l: l,
echo: echoServer,
kubeClient: kubeClient,
sessionMgr: sessMgr,
attemptsStore: store,
blocklist: blockList,
oidcProvider: oidcProvider,
}
e.echo.HTTPErrorHandler = e.errorHandlerChain()
Expand Down Expand Up @@ -202,6 +210,12 @@ func (e *EverestServer) initHTTPServer(ctx context.Context) error {
}
apiGroup.Use(jwtMW)

blocklistMW, err := e.blocklistMiddleWare()
if err != nil {
return err
}
apiGroup.Use(blocklistMW)

apiGroup.Use(e.checkOperatorUpgradeState)
api.RegisterHandlers(apiGroup, e)

Expand Down Expand Up @@ -363,7 +377,7 @@ func (e *EverestServer) getBodyFromContext(ctx echo.Context, into any) error {

func sessionRateLimiter(limit int) (echo.MiddlewareFunc, *RateLimiterMemoryStore) {
allButSession := func(c echo.Context) bool {
return c.Request().Method != echo.POST || c.Request().URL.Path != "/v1/session"
return c.Request().URL.Path != "/v1/session"
}
config := echomiddleware.DefaultRateLimiterConfig
config.Skipper = allButSession
Expand Down Expand Up @@ -414,3 +428,26 @@ func everestErrorHandler(next echo.HTTPErrorHandler) echo.HTTPErrorHandler {
next(err, c)
}
}

func (e *EverestServer) blocklistMiddleWare() (echo.MiddlewareFunc, error) {
skipper, err := newSkipperFunc()
if err != nil {
return nil, err
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if skipper(c) {
return next(c)
}
if allow, err := e.blocklist.IsAllowed(c.Request().Context()); err != nil {
e.l.Error(err)
return err
} else if !allow {
return c.JSON(http.StatusUnauthorized, api.Error{
Message: pointer.ToString("Invalid token"),
})
}
return next(c)
}
}, nil
}
21 changes: 21 additions & 0 deletions internal/server/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import (
"time"

"github.com/AlekSi/pointer"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/labstack/echo/v4"

"github.com/percona/everest/api"
"github.com/percona/everest/pkg/accounts"
"github.com/percona/everest/pkg/common"
)

const (
Expand Down Expand Up @@ -66,6 +68,25 @@ func (e *EverestServer) CreateSession(ctx echo.Context) error {
return ctx.JSON(http.StatusOK, map[string]string{"token": jwtToken})
}

// DeleteSession deletes session.
func (e *EverestServer) DeleteSession(ctx echo.Context) error {
e.attemptsStore.IncreaseTimeout(ctx.RealIP())
c := ctx.Request().Context()
token, ok := c.Value(common.UserCtxKey).(*jwt.Token)
if !ok {
return ctx.JSON(http.StatusUnauthorized, errors.New("failed to get token from context"))
}
if token != nil {
err := e.blocklist.Add(c, token)
if err != nil {
return ctx.JSON(http.StatusRequestTimeout, api.Error{
Message: pointer.To("Incorrect username or password provided"),
})
}
}
return ctx.NoContent(http.StatusNoContent)
}

func sessionErrToHTTPRes(ctx echo.Context, err error) error {
if errors.Is(err, accounts.ErrAccountNotFound) ||
errors.Is(err, accounts.ErrIncorrectPassword) {
Expand Down
2 changes: 2 additions & 0 deletions pkg/common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ const (
EverestAccountsSecretName = "everest-accounts"
// EverestJWTSecretName is the name of the secret that holds JWT secret.
EverestJWTSecretName = "everest-jwt"
// EverestBlocklistSecretName is the name of the secret that holds JWT blocklist.
EverestBlocklistSecretName = "everest-blocklist"
// EverestJWTPrivateKeyFile is the path to the JWT private key.
EverestJWTPrivateKeyFile = "/etc/jwt/id_rsa"
// EverestJWTPublicKeyFile is the path to the JWT public key.
Expand Down
124 changes: 124 additions & 0 deletions pkg/session/blocklist.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package session

import (
"context"
"github.com/percona/everest/pkg/kubernetes"
"github.com/percona/everest/pkg/kubernetes/informer"

"github.com/golang-jwt/jwt/v5"
"github.com/pkg/errors"
"go.uber.org/zap"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/percona/everest/pkg/common"
)

const (
dataKey = "list"
maxRetries = 10
)

type Blocklist interface {
Add(ctx context.Context, token *jwt.Token) error
IsAllowed(ctx context.Context) (bool, error)
}

type blocklist struct {
kubeClient kubernetes.KubernetesConnector
content ContentProcessor
informer *informer.Informer
cachedSecret *corev1.Secret
l *zap.SugaredLogger
}

type ContentProcessor interface {
Add(l *zap.SugaredLogger, secret *corev1.Secret, tokenData string) (*corev1.Secret, bool)
IsBlocked(shortenedToken string) bool
UpdateCache(secret *corev1.Secret)
}

func (b *blocklist) Add(ctx context.Context, token *jwt.Token) error {
shortenedToken, err := shortenToken(token)
if err != nil {
return err
}

for attempts := 0; attempts < maxRetries; attempts++ {
secret, err := b.kubeClient.GetSecret(ctx, common.SystemNamespace, common.EverestBlocklistSecretName)
if err != nil {
if k8serrors.IsNotFound(err) {
_, err = b.kubeClient.CreateSecret(ctx, blockListSecretTemplate(shortenedToken))
if err != nil {
b.l.Errorf("failed to create %s secret: %v", common.EverestBlocklistSecretName, err)
continue
}
return nil
}
}
b.cachedSecret = secret
secret, retryNeeded := b.content.Add(b.l, secret, shortenedToken)
if retryNeeded {
continue
}
updatedSecret, updateErr := b.kubeClient.UpdateSecret(ctx, secret)
if updateErr != nil {
b.l.Errorf("failed to update %s secret: %v", common.EverestBlocklistSecretName, updateErr)
continue
}
b.content.UpdateCache(updatedSecret)
return nil
}
return nil
}

func blockListSecretTemplate(stringData string) *corev1.Secret {
return &corev1.Secret{
TypeMeta: metav1.TypeMeta{
Kind: "Secret",
APIVersion: "v1",
},
ObjectMeta: metav1.ObjectMeta{
Name: common.EverestBlocklistSecretName,
Namespace: common.SystemNamespace,
},
StringData: map[string]string{
dataKey: stringData,
},
}
}

func (b *blocklist) IsAllowed(ctx context.Context) (bool, error) {
token, ok := ctx.Value(common.UserCtxKey).(*jwt.Token)
if !ok {
return false, errors.New("failed to get token from context")
}

shortenedToken, err := shortenToken(token)
if err != nil {
return false, errors.Wrap(err, "failed to shrink token")
}

return !b.content.IsBlocked(shortenedToken), nil
}

func NewBlocklist(ctx context.Context, kubeClient kubernetes.KubernetesConnector, logger *zap.SugaredLogger) (Blocklist, error) {
// read the existing blocklist token or create it
secret, err := kubeClient.GetSecret(ctx, common.SystemNamespace, common.EverestBlocklistSecretName)
if err != nil {
if !k8serrors.IsNotFound(err) {
return nil, errors.Wrap(err, "failed to get secret")
}
var createErr error
secret, createErr = kubeClient.CreateSecret(ctx, blockListSecretTemplate(""))
if createErr != nil {
return nil, errors.Wrap(createErr, "failed to create secret")
}
}
return &blocklist{
kubeClient: kubeClient,
content: newContentProcessor(secret),
l: logger,
}, nil
}
Loading
Loading