fix: allow non expireable API key (#8013)

* fix: allow non expireable API key

* fix: clean up pat to API key middleware

* fix: address comments

* fix: update response of create api key

* fix: gettable struct

* fix(api-key): frontend changes for api key refactor

---------

Co-authored-by: vikrantgupta25 <vikrant@signoz.io>
This commit is contained in:
Nityananda Gohain 2025-05-23 13:17:20 +05:30 committed by GitHub
parent 6090a6be6e
commit 77d1492aac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 250 additions and 235 deletions

View File

@ -0,0 +1,85 @@
package middleware
import (
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"go.uber.org/zap"
)
type APIKey struct {
store sqlstore.SQLStore
uuid *authtypes.UUID
headers []string
}
func NewAPIKey(store sqlstore.SQLStore, headers []string) *APIKey {
return &APIKey{store: store, uuid: authtypes.NewUUID(), headers: headers}
}
func (a *APIKey) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var values []string
var apiKeyToken string
var apiKey types.StorableAPIKey
for _, header := range a.headers {
values = append(values, r.Header.Get(header))
}
ctx, err := a.uuid.ContextFromRequest(r.Context(), values...)
if err != nil {
next.ServeHTTP(w, r)
return
}
apiKeyToken, ok := authtypes.UUIDFromContext(ctx)
if !ok {
next.ServeHTTP(w, r)
return
}
err = a.store.BunDB().NewSelect().Model(&apiKey).Where("token = ?", apiKeyToken).Scan(r.Context())
if err != nil {
next.ServeHTTP(w, r)
return
}
// allow the APIKey if expires_at is not set
if apiKey.ExpiresAt.Before(time.Now()) && !apiKey.ExpiresAt.Equal(types.NEVER_EXPIRES) {
next.ServeHTTP(w, r)
return
}
// get user from db
user := types.User{}
err = a.store.BunDB().NewSelect().Model(&user).Where("id = ?", apiKey.UserID).Scan(r.Context())
if err != nil {
next.ServeHTTP(w, r)
return
}
jwt := authtypes.Claims{
UserID: user.ID.String(),
Role: apiKey.Role,
Email: user.Email,
OrgID: user.OrgID,
}
ctx = authtypes.NewContextWithClaims(ctx, jwt)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
apiKey.LastUsed = time.Now()
_, err = a.store.BunDB().NewUpdate().Model(&apiKey).Column("last_used").Where("token = ?", apiKeyToken).Where("revoked = false").Exec(r.Context())
if err != nil {
zap.L().Error("Failed to update APIKey last used in db", zap.Error(err))
}
})
}

View File

@ -1,84 +0,0 @@
package middleware
import (
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"go.uber.org/zap"
)
type Pat struct {
store sqlstore.SQLStore
uuid *authtypes.UUID
headers []string
}
func NewPat(store sqlstore.SQLStore, headers []string) *Pat {
return &Pat{store: store, uuid: authtypes.NewUUID(), headers: headers}
}
func (p *Pat) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var values []string
var patToken string
var pat types.StorableAPIKey
for _, header := range p.headers {
values = append(values, r.Header.Get(header))
}
ctx, err := p.uuid.ContextFromRequest(r.Context(), values...)
if err != nil {
next.ServeHTTP(w, r)
return
}
patToken, ok := authtypes.UUIDFromContext(ctx)
if !ok {
next.ServeHTTP(w, r)
return
}
err = p.store.BunDB().NewSelect().Model(&pat).Where("token = ?", patToken).Scan(r.Context())
if err != nil {
next.ServeHTTP(w, r)
return
}
if pat.ExpiresAt.Before(time.Now()) {
next.ServeHTTP(w, r)
return
}
// get user from db
user := types.User{}
err = p.store.BunDB().NewSelect().Model(&user).Where("id = ?", pat.UserID).Scan(r.Context())
if err != nil {
next.ServeHTTP(w, r)
return
}
jwt := authtypes.Claims{
UserID: user.ID.String(),
Role: pat.Role,
Email: user.Email,
OrgID: user.OrgID,
}
ctx = authtypes.NewContextWithClaims(ctx, jwt)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
pat.LastUsed = time.Now()
_, err = p.store.BunDB().NewUpdate().Model(&pat).Column("last_used").Where("token = ?", patToken).Where("revoked = false").Exec(r.Context())
if err != nil {
zap.L().Error("Failed to update PAT last used in db, err: %v", zap.Error(err))
}
})
}

View File

@ -215,6 +215,12 @@ func (h *Handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgId is not a valid uuid-v7"))
return
}
userID, err := valuer.NewUUID(claims.UserID)
if err != nil {
render.Error(w, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "userId is not a valid uuid-v7"))
@ -244,8 +250,14 @@ func (h *Handler) CreateAPIKey(w http.ResponseWriter, r *http.Request) {
return
}
createdApiKey, err := h.module.GetAPIKey(ctx, orgID, apiKey.ID)
if err != nil {
render.Error(w, err)
return
}
// just corrected the status code, response is same,
render.Success(w, http.StatusCreated, apiKey)
render.Success(w, http.StatusCreated, createdApiKey)
}
func (h *Handler) ListAPIKeys(w http.ResponseWriter, r *http.Request) {

View File

@ -258,7 +258,7 @@ func (s *Server) createPrivateServer(apiHandler *api.APIHandler) (*http.Server,
r := baseapp.NewRouter()
r.Use(middleware.NewAuth(zap.L(), s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap)
r.Use(eemiddleware.NewPat(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}).Wrap)
r.Use(eemiddleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}).Wrap)
r.Use(middleware.NewTimeout(zap.L(),
s.serverOptions.Config.APIServer.Timeout.ExcludedRoutes,
s.serverOptions.Config.APIServer.Timeout.Default,
@ -290,7 +290,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
am := middleware.NewAuthZ(s.serverOptions.SigNoz.Instrumentation.Logger())
r.Use(middleware.NewAuth(zap.L(), s.serverOptions.Jwt, []string{"Authorization", "Sec-WebSocket-Protocol"}).Wrap)
r.Use(eemiddleware.NewPat(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}).Wrap)
r.Use(eemiddleware.NewAPIKey(s.serverOptions.SigNoz.SQLStore, []string{"SIGNOZ-API-KEY"}).Wrap)
r.Use(middleware.NewTimeout(zap.L(),
s.serverOptions.Config.APIServer.Timeout.ExcludedRoutes,
s.serverOptions.Config.APIServer.Timeout.Default,

View File

@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { APIKeyProps, CreateAPIKeyProps } from 'types/api/pat/types';
const createAPIKey = async (
props: CreateAPIKeyProps,
): Promise<SuccessResponse<APIKeyProps> | ErrorResponse> => {
try {
const response = await axios.post('/pats', {
...props,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default createAPIKey;

View File

@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { AllAPIKeyProps } from 'types/api/pat/types';
const deleteAPIKey = async (
id: string,
): Promise<SuccessResponse<AllAPIKeyProps> | ErrorResponse> => {
try {
const response = await axios.delete(`/pats/${id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default deleteAPIKey;

View File

@ -1,24 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, Props } from 'types/api/alerts/get';
const get = async (
props: Props,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.get(`/pats/${props.id}`);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default get;

View File

@ -1,6 +0,0 @@
import axios from 'api';
import { AxiosResponse } from 'axios';
import { AllAPIKeyProps } from 'types/api/pat/types';
export const getAllAPIKeys = (): Promise<AxiosResponse<AllAPIKeyProps>> =>
axios.get(`/pats`);

View File

@ -1,26 +0,0 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { PayloadProps, UpdateAPIKeyProps } from 'types/api/pat/types';
const updateAPIKey = async (
props: UpdateAPIKeyProps,
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
try {
const response = await axios.put(`/pats/${props.id}`, {
...props.data,
});
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};
export default updateAPIKey;

View File

@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
APIKeyProps,
CreateAPIKeyProps,
CreatePayloadProps,
} from 'types/api/pat/types';
const create = async (
props: CreateAPIKeyProps,
): Promise<SuccessResponseV2<APIKeyProps>> => {
try {
const response = await axios.post<CreatePayloadProps>('/pats', {
...props,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default create;

View File

@ -0,0 +1,19 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
const deleteAPIKey = async (id: string): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.delete(`/pats/${id}`);
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default deleteAPIKey;

View File

@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { AllAPIKeyProps, APIKeyProps } from 'types/api/pat/types';
const list = async (): Promise<SuccessResponseV2<APIKeyProps[]>> => {
try {
const response = await axios.get<AllAPIKeyProps>('/pats');
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default list;

View File

@ -0,0 +1,24 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UpdateAPIKeyProps } from 'types/api/pat/types';
const updateAPIKey = async (
props: UpdateAPIKeyProps,
): Promise<SuccessResponseV2<null>> => {
try {
const response = await axios.put(`/pats/${props.id}`, {
...props.data,
});
return {
httpStatusCode: response.status,
data: null,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updateAPIKey;

View File

@ -20,12 +20,10 @@ import {
} from 'antd';
import { NotificationInstance } from 'antd/es/notification/interface';
import { CollapseProps } from 'antd/lib';
import createAPIKeyApi from 'api/APIKeys/createAPIKey';
import deleteAPIKeyApi from 'api/APIKeys/deleteAPIKey';
import updateAPIKeyApi from 'api/APIKeys/updateAPIKey';
import axios, { AxiosError } from 'axios';
import createAPIKeyApi from 'api/v1/pats/create';
import deleteAPIKeyApi from 'api/v1/pats/delete';
import updateAPIKeyApi from 'api/v1/pats/update';
import cx from 'classnames';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useGetAllAPIKeys } from 'hooks/APIKeys/useGetAllAPIKeys';
@ -50,6 +48,7 @@ import { ChangeEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import APIError from 'types/api/error';
import { APIKeyProps } from 'types/api/pat/types';
import { USER_ROLES } from 'types/roles';
@ -57,10 +56,11 @@ dayjs.extend(relativeTime);
export const showErrorNotification = (
notifications: NotificationInstance,
err: Error,
err: APIError,
): void => {
notifications.error({
message: axios.isAxiosError(err) ? err.message : SOMETHING_WENT_WRONG,
message: err.getErrorCode(),
description: err.getErrorMessage(),
});
};
@ -177,22 +177,22 @@ function APIKeys(): JSX.Element {
} = useGetAllAPIKeys();
useEffect(() => {
setActiveAPIKey(APIKeys?.data.data[0]);
setActiveAPIKey(APIKeys?.data?.[0]);
}, [APIKeys]);
useEffect(() => {
setDataSource(APIKeys?.data.data || []);
}, [APIKeys?.data.data]);
setDataSource(APIKeys?.data || []);
}, [APIKeys?.data]);
useEffect(() => {
if (isError) {
showErrorNotification(notifications, error as AxiosError);
showErrorNotification(notifications, error as APIError);
}
}, [error, isError, notifications]);
const handleSearch = (e: ChangeEvent<HTMLInputElement>): void => {
setSearchValue(e.target.value);
const filteredData = APIKeys?.data?.data?.filter(
const filteredData = APIKeys?.data?.filter(
(key: APIKeyProps) =>
key &&
key.name &&
@ -210,12 +210,12 @@ function APIKeys(): JSX.Element {
{
onSuccess: (data) => {
setShowNewAPIKeyDetails(true);
setActiveAPIKey(data.payload);
setActiveAPIKey(data.data);
refetchAPIKeys();
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
showErrorNotification(notifications, error as APIError);
},
},
);
@ -228,7 +228,7 @@ function APIKeys(): JSX.Element {
setIsEditModalOpen(false);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
showErrorNotification(notifications, error as APIError);
},
},
);
@ -241,7 +241,7 @@ function APIKeys(): JSX.Element {
setIsDeleteModalOpen(false);
},
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
showErrorNotification(notifications, error as APIError);
},
},
);
@ -445,10 +445,12 @@ function APIKeys(): JSX.Element {
<Col span={6}> Creator </Col>
<Col span={12} className="user-info">
<Avatar className="user-avatar" size="small">
{APIKey?.createdByUser?.name?.substring(0, 1)}
{APIKey?.createdByUser?.displayName?.substring(0, 1)}
</Avatar>
<Typography.Text>{APIKey.createdByUser?.name}</Typography.Text>
<Typography.Text>
{APIKey.createdByUser?.displayName}
</Typography.Text>
<div className="user-email">{APIKey.createdByUser?.email}</div>
</Col>
@ -829,10 +831,12 @@ function APIKeys(): JSX.Element {
<Col span={16} className="user-info">
<Avatar className="user-avatar" size="small">
{activeAPIKey?.createdByUser?.name?.substring(0, 1)}
{activeAPIKey?.createdByUser?.displayName?.substring(0, 1)}
</Avatar>
<Typography.Text>{activeAPIKey?.createdByUser?.name}</Typography.Text>
<Typography.Text>
{activeAPIKey?.createdByUser?.displayName}
</Typography.Text>
<div className="user-email">{activeAPIKey?.createdByUser?.email}</div>
</Col>

View File

@ -1,13 +1,14 @@
import { getAllAPIKeys } from 'api/APIKeys/getAllAPIKeys';
import { AxiosError, AxiosResponse } from 'axios';
import list from 'api/v1/pats/list';
import { useQuery, UseQueryResult } from 'react-query';
import { AllAPIKeyProps } from 'types/api/pat/types';
import { SuccessResponseV2 } from 'types/api';
import APIError from 'types/api/error';
import { APIKeyProps } from 'types/api/pat/types';
export const useGetAllAPIKeys = (): UseQueryResult<
AxiosResponse<AllAPIKeyProps>,
AxiosError
SuccessResponseV2<APIKeyProps[]>,
APIError
> =>
useQuery<AxiosResponse<AllAPIKeyProps>, AxiosError>({
useQuery<SuccessResponseV2<APIKeyProps[]>, APIError>({
queryKey: ['APIKeys'],
queryFn: () => getAllAPIKeys(),
queryFn: () => list(),
});

View File

@ -2,9 +2,7 @@ export interface User {
createdAt?: number;
email?: string;
id: string;
name?: string;
notFound?: boolean;
profilePictureURL?: string;
displayName?: string;
}
export interface APIKeyProps {
@ -20,6 +18,11 @@ export interface APIKeyProps {
lastUsed?: number;
}
export interface CreatePayloadProps {
data: APIKeyProps;
status: string;
}
export interface CreateAPIKeyProps {
name: string;
expiresInDays: number;

View File

@ -10,6 +10,8 @@ import (
"github.com/uptrace/bun"
)
var NEVER_EXPIRES = time.Unix(0, 0)
type PostableAPIKey struct {
Name string `json:"name"`
Role Role `json:"role"`
@ -20,15 +22,15 @@ type GettableAPIKey struct {
Identifiable
TimeAuditable
UserAuditable
Token string `json:"token"`
Role Role `json:"role"`
Name string `json:"name"`
ExpiresAt int64 `json:"expiresAt"`
LastUsed int64 `json:"lastUsed"`
Revoked bool `json:"revoked"`
UserID string `json:"userId"`
CreatedBy *User `json:"createdBy"`
UpdatedBy *User `json:"updatedBy"`
Token string `json:"token"`
Role Role `json:"role"`
Name string `json:"name"`
ExpiresAt int64 `json:"expiresAt"`
LastUsed int64 `json:"lastUsed"`
Revoked bool `json:"revoked"`
UserID string `json:"userId"`
CreatedByUser *User `json:"createdByUser"`
UpdatedByUser *User `json:"updatedByUser"`
}
type OrgUserAPIKey struct {
@ -65,7 +67,9 @@ type StorableAPIKey struct {
func NewStorableAPIKey(name string, userID valuer.UUID, role Role, expiresAt int64) (*StorableAPIKey, error) {
// validate
if expiresAt <= 0 {
// we allow the APIKey if expiresAt is not set, which means it never expires
if expiresAt < 0 {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "expiresAt must be greater than 0")
}
@ -82,6 +86,11 @@ func NewStorableAPIKey(name string, userID valuer.UUID, role Role, expiresAt int
// expiresAt = now.Unix() + (expiresAt * 24 * 60 * 60)
expiresAtTime := now.AddDate(0, 0, int(expiresAt))
// if the expiresAt is 0, it means the APIKey never expires
if expiresAt == 0 {
expiresAtTime = NEVER_EXPIRES
}
// Generate a 32-byte random token.
token := make([]byte, 32)
_, err := rand.Read(token)
@ -129,7 +138,7 @@ func NewGettableAPIKeyFromStorableAPIKey(storableAPIKey *StorableAPIKeyUser) *Ge
LastUsed: lastUsed,
Revoked: storableAPIKey.Revoked,
UserID: storableAPIKey.UserID.String(),
CreatedBy: storableAPIKey.CreatedByUser,
UpdatedBy: storableAPIKey.UpdatedByUser,
CreatedByUser: storableAPIKey.CreatedByUser,
UpdatedByUser: storableAPIKey.UpdatedByUser,
}
}