Compare commits

..

2 Commits

Author SHA1 Message Date
SagarRajput-7
5f647cbc78 Merge branch 'main' into sso-form-payload 2026-06-08 17:33:36 +05:30
SagarRajput-7
8749523cb4 fix(sso): strip conditional google auth and role mapping fields from save payload 2026-06-08 17:32:59 +05:30
19 changed files with 318 additions and 435 deletions

View File

@@ -39,7 +39,6 @@ jobs:
matrix:
suite:
- alerts
- basepath
- callbackauthn
- cloudintegrations
- dashboard
@@ -84,7 +83,7 @@ jobs:
run: |
cd tests && uv sync
- name: webdriver
if: matrix.suite == 'callbackauthn' || matrix.suite == 'basepath'
if: matrix.suite == 'callbackauthn'
run: |
wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add -
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | sudo tee -a /etc/apt/sources.list.d/google-chrome.list

View File

@@ -91,7 +91,7 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore, config authz.Config, _ licensing.Licensing, _ []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error) {
openfgaDataStore, err := openfgaserver.NewSQLStore(sqlstore, config)

View File

@@ -107,17 +107,17 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
sqlstoreProviderFactories(),
signoz.NewTelemetryStoreProviderFactories(),
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing, config.Global)
samlCallbackAuthN, err := samlcallbackauthn.New(ctx, store, licensing)
if err != nil {
return nil, err
}
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings, config.Global)
oidcCallbackAuthN, err := oidccallbackauthn.New(store, licensing, providerSettings)
if err != nil {
return nil, err
}
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing, config.Global)
authNs, err := signoz.NewAuthNs(ctx, providerSettings, store, licensing)
if err != nil {
return nil, err
}

View File

@@ -5,12 +5,10 @@ import (
"fmt"
"log/slog"
"net/url"
"path"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -28,14 +26,13 @@ var defaultScopes []string = []string{"email", "profile", oidc.ScopeOpenID}
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
globalConfig global.Config
settings factory.ScopedProviderSettings
store authtypes.AuthNStore
licensing licensing.Licensing
httpClient *client.Client
}
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSettings factory.ProviderSettings) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn")
httpClient, err := client.New(providerSettings.Logger, providerSettings.TracerProvider, providerSettings.MeterProvider)
@@ -44,11 +41,10 @@ func New(store authtypes.AuthNStore, licensing licensing.Licensing, providerSett
}
return &AuthN{
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
globalConfig: globalConfig,
settings: settings,
store: store,
licensing: licensing,
httpClient: httpClient,
}, nil
}
@@ -201,7 +197,7 @@ func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.UR
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
Path: redirectPath,
}).String(),
}, nil
}

View File

@@ -6,12 +6,10 @@ import (
"encoding/base64"
"encoding/pem"
"net/url"
"path"
"strings"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -26,16 +24,14 @@ const (
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
licensing licensing.Licensing
globalConfig global.Config
store authtypes.AuthNStore
licensing licensing.Licensing
}
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (*AuthN, error) {
func New(ctx context.Context, store authtypes.AuthNStore, licensing licensing.Licensing) (*AuthN, error) {
return &AuthN{
store: store,
licensing: licensing,
globalConfig: globalConfig,
store: store,
licensing: licensing,
}, nil
}
@@ -136,7 +132,7 @@ func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDoma
return nil, err
}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: path.Join(a.globalConfig.ExternalPath(), redirectPath)}
acsURL := &url.URL{Scheme: siteURL.Scheme, Host: siteURL.Host, Path: redirectPath}
// Note:
// The ServiceProviderIssuer is the client id in case of keycloak. Since we set it to the host here, we need to set the client id == host in keycloak.

View File

@@ -96,14 +96,28 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return undefined;
}
const { domainToAdminEmailList, ...rest } = config;
const {
domainToAdminEmailList,
allowedGroups,
serviceAccountJson,
domainToAdminEmail: _domainToAdminEmail,
fetchTransitiveGroupMembership,
...rest
} = config;
const domainToAdminEmail = convertDomainMappingsToRecord(
domainToAdminEmailList,
);
return {
...rest,
domainToAdminEmail: domainToAdminEmail ?? {},
...(rest.fetchGroups
? {
allowedGroups,
serviceAccountJson,
domainToAdminEmail: domainToAdminEmail ?? {},
fetchTransitiveGroupMembership,
}
: { domainToAdminEmail: {} }),
};
}, [form]);
@@ -129,7 +143,7 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
return {
...rest,
groupMappings: groupMappings ?? {},
groupMappings: rest.useRoleAttribute ? undefined : (groupMappings ?? {}),
};
}, [form]);

View File

@@ -0,0 +1,195 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { rest, server } from 'mocks-server/server';
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
import CreateEdit from '../CreateEdit/CreateEdit';
import {
AUTH_DOMAINS_UPDATE_ENDPOINT,
mockDomainWithRoleMapping,
mockGoogleAuthDomain,
mockGoogleAuthWithWorkspaceGroups,
mockOidcWithClaimMapping,
mockSamlWithAttributeMapping,
mockUpdateSuccessResponse,
} from './mocks';
// @signozhq/ui/button internal effects block form.validateFields() in tests
jest.mock('@signozhq/ui/button', () => ({
...jest.requireActual('@signozhq/ui/button'),
Button: ({
children,
onClick,
loading,
disabled,
'aria-label': ariaLabel,
prefix,
suffix,
}: {
children?: React.ReactNode;
onClick?: React.MouseEventHandler<HTMLButtonElement>;
loading?: boolean;
disabled?: boolean;
'aria-label'?: string;
prefix?: React.ReactNode;
suffix?: React.ReactNode;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled || loading}
aria-label={ariaLabel}
>
{prefix}
{children}
{suffix}
</button>
),
}));
type SavedPayload = {
config: {
googleAuthConfig?: Record<string, unknown>;
samlConfig?: Record<string, unknown>;
oidcConfig?: Record<string, unknown>;
roleMapping?: Record<string, unknown>;
};
};
async function submitForm(
record: AuthtypesGettableAuthDomainDTO,
): Promise<SavedPayload> {
const requests: SavedPayload[] = [];
server.use(
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
requests.push((await req.json()) as SavedPayload);
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
}),
);
render(<CreateEdit isCreate={false} record={record} onClose={jest.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() => expect(requests).toHaveLength(1));
return requests[0];
}
describe('CreateEdit — payload sanitization', () => {
afterEach(() => server.resetHandlers());
describe('Google Auth', () => {
it('sends core fields and omits workspace fields when fetchGroups is not set', async () => {
const payload = await submitForm(mockGoogleAuthDomain);
const g = payload.config.googleAuthConfig;
expect(g?.clientId).toBe('test-client-id');
expect(g?.clientSecret).toBe('test-client-secret');
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
expect(g?.domainToAdminEmail).toStrictEqual({});
});
it('strips workspace fields when fetchGroups is false', async () => {
const payload = await submitForm({
...mockGoogleAuthWithWorkspaceGroups,
config: {
...mockGoogleAuthWithWorkspaceGroups.config,
googleAuthConfig: {
...mockGoogleAuthWithWorkspaceGroups.config?.googleAuthConfig,
fetchGroups: false,
},
},
});
const g = payload.config.googleAuthConfig;
expect(g?.fetchGroups).toBe(false);
expect(g?.allowedGroups).toBeUndefined();
expect(g?.serviceAccountJson).toBeUndefined();
expect(g?.fetchTransitiveGroupMembership).toBeUndefined();
expect(g?.domainToAdminEmail).toStrictEqual({});
});
it('includes all workspace fields when fetchGroups is true', async () => {
const payload = await submitForm(mockGoogleAuthWithWorkspaceGroups);
const g = payload.config.googleAuthConfig;
expect(g?.fetchGroups).toBe(true);
expect(g?.serviceAccountJson).toBe('{"type": "service_account"}');
expect(g?.fetchTransitiveGroupMembership).toBe(true);
expect(g?.allowedGroups).toStrictEqual([
'allowed-group-1',
'allowed-group-2',
]);
expect(g?.domainToAdminEmail).toStrictEqual({
'google-groups.com': 'admin@google-groups.com',
});
});
});
describe('SAML', () => {
it('sends core and attributeMapping fields', async () => {
const payload = await submitForm(mockSamlWithAttributeMapping);
const s = payload.config.samlConfig;
expect(s?.samlIdp).toBe('https://idp.saml-attrs.com/sso');
expect(s?.samlEntity).toBe('urn:saml-attrs:idp');
expect(s?.samlCert).toBe('MOCK_CERTIFICATE_ATTRS');
expect(s?.insecureSkipAuthNRequestsSigned).toBe(true);
const attr = s?.attributeMapping as Record<string, unknown>;
expect(attr?.name).toBe('user_display_name');
expect(attr?.groups).toBe('member_of');
expect(attr?.role).toBe('signoz_role');
});
});
describe('OIDC', () => {
it('sends all fields including claimMapping', async () => {
const payload = await submitForm(mockOidcWithClaimMapping);
const o = payload.config.oidcConfig;
expect(o?.issuer).toBe('https://oidc.claims.com');
expect(o?.issuerAlias).toBe('https://alias.claims.com');
expect(o?.clientId).toBe('claims-client-id');
expect(o?.clientSecret).toBe('claims-client-secret');
expect(o?.insecureSkipEmailVerified).toBe(true);
expect(o?.getUserInfo).toBe(true);
const claim = o?.claimMapping as Record<string, unknown>;
expect(claim?.email).toBe('user_email');
expect(claim?.name).toBe('display_name');
expect(claim?.groups).toBe('user_groups');
expect(claim?.role).toBe('user_role');
});
});
describe('Role Mapping', () => {
it('strips groupMappings when useRoleAttribute is true', async () => {
const payload = await submitForm({
...mockDomainWithRoleMapping,
config: {
...mockDomainWithRoleMapping.config,
roleMapping: {
...mockDomainWithRoleMapping.config?.roleMapping,
useRoleAttribute: true,
},
},
});
expect(payload.config.roleMapping?.useRoleAttribute).toBe(true);
expect(payload.config.roleMapping?.groupMappings).toBeUndefined();
});
it('sends groupMappings when useRoleAttribute is false', async () => {
const payload = await submitForm(mockDomainWithRoleMapping);
expect(payload.config.roleMapping?.useRoleAttribute).toBe(false);
expect(payload.config.roleMapping?.groupMappings).toStrictEqual({
'admin-group': 'ADMIN',
'dev-team': 'EDITOR',
viewers: 'VIEWER',
});
});
});
});

View File

@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"net/url"
"path"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
@@ -15,7 +14,6 @@ import (
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -31,13 +29,12 @@ var scopes []string = []string{"email", "profile"}
var _ authn.CallbackAuthN = (*AuthN)(nil)
type AuthN struct {
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
globalConfig global.Config
store authtypes.AuthNStore
settings factory.ScopedProviderSettings
httpClient *client.Client
}
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings, globalConfig global.Config) (*AuthN, error) {
func New(ctx context.Context, store authtypes.AuthNStore, providerSettings factory.ProviderSettings) (*AuthN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn")
httpClient, err := client.New(settings.Logger(), providerSettings.TracerProvider, providerSettings.MeterProvider)
@@ -46,10 +43,9 @@ func New(ctx context.Context, store authtypes.AuthNStore, providerSettings facto
}
return &AuthN{
store: store,
settings: settings,
httpClient: httpClient,
globalConfig: globalConfig,
store: store,
settings: settings,
httpClient: httpClient,
}, nil
}
@@ -182,7 +178,7 @@ func (a *AuthN) oauth2Config(siteURL *url.URL, authDomain *authtypes.AuthDomain,
RedirectURL: (&url.URL{
Scheme: siteURL.Scheme,
Host: siteURL.Host,
Path: path.Join(a.globalConfig.ExternalPath(), redirectPath),
Path: redirectPath,
}).String(),
}
}

View File

@@ -4,11 +4,9 @@ import (
"context"
"net/http"
"net/url"
"path"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/session"
@@ -17,12 +15,11 @@ import (
)
type handler struct {
module session.Module
globalConfig global.Config
module session.Module
}
func NewHandler(module session.Module, globalConfig global.Config) session.Handler {
return &handler{module: module, globalConfig: globalConfig}
func NewHandler(module session.Module) session.Handler {
return &handler{module: module}
}
func (handler *handler) GetSessionContext(rw http.ResponseWriter, req *http.Request) {
@@ -161,13 +158,13 @@ func (handler *handler) DeleteSession(rw http.ResponseWriter, req *http.Request)
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) getRedirectURLFromErr(err error) string {
func (*handler) getRedirectURLFromErr(err error) string {
values := errors.AsURLValues(err)
values.Add("callbackauthnerr", "true")
return (&url.URL{
// When UI is being served on a prefix, we need to redirect to the login page on the prefix.
Path: path.Join(handler.globalConfig.ExternalPath(), "/login"),
Path: "/login",
RawQuery: values.Encode(),
}).String()
}

View File

@@ -7,15 +7,14 @@ import (
"github.com/SigNoz/signoz/pkg/authn/callbackauthn/googlecallbackauthn"
"github.com/SigNoz/signoz/pkg/authn/passwordauthn/emailpasswordauthn"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing, globalConfig global.Config) (map[authtypes.AuthNProvider]authn.AuthN, error) {
func NewAuthNs(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
emailPasswordAuthN := emailpasswordauthn.New(store)
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings, globalConfig)
googleCallbackAuthN, err := googlecallbackauthn.New(ctx, store, providerSettings)
if err != nil {
return nil, err
}

View File

@@ -275,14 +275,14 @@ func NewQuerierProviderFactories(telemetryStore telemetrystore.TelemetryStore, p
)
}
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers, globalConfig global.Config) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.AuthZ, modules Modules, handlers Handlers) factory.NamedMap[factory.ProviderFactory[apiserver.APIServer, apiserver.Config]] {
return factory.MustNewNamedMap(
signozapiserver.NewFactory(
orgGetter,
authz,
implorganization.NewHandler(modules.OrgGetter, modules.OrgSetter),
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
implsession.NewHandler(modules.Session, globalConfig),
implsession.NewHandler(modules.Session),
implauthdomain.NewHandler(modules.AuthDomain),
implpreference.NewHandler(modules.Preference),
handlers.Global,

View File

@@ -95,7 +95,6 @@ func TestNewProviderFactories(t *testing.T) {
nil,
Modules{},
Handlers{},
global.Config{},
)
})
}

View File

@@ -542,7 +542,7 @@ func New(
ctx,
providerSettings,
config.APIServer,
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers, config.Global),
NewAPIServerProviderFactories(orgGetter, authz, modules, handlers),
"signoz",
)
if err != nil {

140
tests/fixtures/auth.py vendored
View File

@@ -56,18 +56,11 @@ def _login(signoz: types.SigNoz, email: str, password: str) -> str:
return login.json()["data"]["accessToken"]
def register_admin(
signoz: types.SigNoz,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "create_user_admin",
base_path: str = "",
) -> types.Operation:
"""Register the first admin (creates the org), under base_path. Reuse-wrapped."""
def create() -> types.Operation:
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
def create() -> None:
response = requests.post(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v1/register"),
signoz.self.host_configs["8080"].get("/api/v1/register"),
json={
"name": USER_ADMIN_NAME,
"orgName": "",
@@ -90,7 +83,7 @@ def register_admin(
return reuse.wrap(
request,
pytestconfig,
cache_key,
"create_user_admin",
lambda: types.Operation(name=""),
create,
delete,
@@ -98,86 +91,86 @@ def register_admin(
)
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
return register_admin(signoz, request, pytestconfig)
def session_context_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str], dict]:
"""Build a callable that fetches the session context for an email (under base_path)."""
def fetch_session_context(email: str) -> dict:
@pytest.fixture(name="get_session_context", scope="function")
def get_session_context(signoz: types.SigNoz) -> Callable[[str, str], str]:
def _get_session_context(email: str) -> str:
response = requests.get(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={
"email": email,
"ref": f"{signoz.self.host_configs['8080'].base()}",
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
return response.json()["data"]
return fetch_session_context
@pytest.fixture(name="get_session_context", scope="function")
def get_session_context(signoz: types.SigNoz) -> Callable[[str], dict]:
return session_context_getter(signoz)
def token_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str, str], str]:
"""Build a callable that logs in (email/password) and returns the access token (under base_path)."""
def fetch_token(email: str, password: str) -> str:
context = requests.get(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
timeout=5,
)
assert context.status_code == HTTPStatus.OK
org_id = context.json()["data"]["orgs"][0]["id"]
login = requests.post(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/email_password"),
json={"email": email, "password": password, "orgId": org_id},
timeout=5,
)
assert login.status_code == HTTPStatus.OK
return login.json()["data"]["accessToken"]
return fetch_token
return _get_session_context
@pytest.fixture(name="get_token", scope="function")
def get_token(signoz: types.SigNoz) -> Callable[[str, str], str]:
return token_getter(signoz)
def tokens_getter(signoz: types.SigNoz, base_path: str = "") -> Callable[[str, str], tuple[str, str]]:
"""Build a callable that logs in and returns the (access, refresh) token pair (under base_path)."""
def fetch_tokens(email: str, password: str) -> tuple[str, str]:
context = requests.get(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/context"),
params={"email": email, "ref": f"{signoz.self.host_configs['8080'].base()}"},
def _get_token(email: str, password: str) -> str:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={
"email": email,
"ref": f"{signoz.self.host_configs['8080'].base()}",
},
timeout=5,
)
assert context.status_code == HTTPStatus.OK
org_id = context.json()["data"]["orgs"][0]["id"]
login = requests.post(
signoz.self.host_configs["8080"].get(f"{base_path}/api/v2/sessions/email_password"),
json={"email": email, "password": password, "orgId": org_id},
assert response.status_code == HTTPStatus.OK
org_id = response.json()["data"]["orgs"][0]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={
"email": email,
"password": password,
"orgId": org_id,
},
timeout=5,
)
assert login.status_code == HTTPStatus.OK
data = login.json()["data"]
return data["accessToken"], data["refreshToken"]
return fetch_tokens
assert response.status_code == HTTPStatus.OK
return response.json()["data"]["accessToken"]
return _get_token
@pytest.fixture(name="get_tokens", scope="function")
def get_tokens(signoz: types.SigNoz) -> Callable[[str, str], tuple[str, str]]:
return tokens_getter(signoz)
def _get_tokens(email: str, password: str) -> str:
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={
"email": email,
"ref": f"{signoz.self.host_configs['8080'].base()}",
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
org_id = response.json()["data"]["orgs"][0]["id"]
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={
"email": email,
"password": password,
"orgId": org_id,
},
timeout=5,
)
assert response.status_code == HTTPStatus.OK
access_token = response.json()["data"]["accessToken"]
refresh_token = response.json()["data"]["refreshToken"]
return access_token, refresh_token
return _get_tokens
@pytest.fixture(name="apply_license", scope="package")
@@ -277,7 +270,6 @@ def add_license(
signoz: types.SigNoz,
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str], # pylint: disable=redefined-outer-name
base_path: str = "",
) -> None:
make_http_mocks(
signoz.zeus,
@@ -316,7 +308,7 @@ def add_license(
access_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
url=signoz.self.host_configs["8080"].get(f"{base_path}/api/v3/licenses"),
url=signoz.self.host_configs["8080"].get("/api/v3/licenses"),
json={"key": "secret-key"},
headers={"Authorization": "Bearer " + access_token},
timeout=5,

View File

@@ -1,126 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
from urllib.parse import urlparse
import requests
from selenium import webdriver
from wiremock.resources.mappings import Mapping
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license, assert_user_has_role
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
# SigNoz is served under /signoz, so the OIDC callback registered with the IdP
# must include the prefix to match the backend-generated redirect URI.
BASE_PATH = "/signoz"
OIDC_CALLBACK_PATH = f"{BASE_PATH}/api/v1/complete/oidc"
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
) -> None:
"""
Applies a license to the signoz instance. add_license is a plain function
called from the test (function scope), so the function-scoped make_http_mocks
fixture is safe to use; base_path prefixes the licensing API call.
"""
add_license(signoz, make_http_mocks, get_token, base_path=BASE_PATH)
def test_create_auth_domain(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
create_oidc_client: Callable[[str, str], None],
get_oidc_settings: Callable[[str], dict],
get_token: Callable[[str, str], str],
) -> None:
"""
Creates an OIDC auth domain in SigNoz served under a base path. The callback
registered with the IdP carries the /signoz prefix.
"""
client_id = f"oidc.basepath.test.{signoz.self.host_configs['8080'].address}:{signoz.self.host_configs['8080'].port}"
# Create an oidc client in the idp with the prefixed callback.
create_oidc_client(client_id, OIDC_CALLBACK_PATH)
# Get the oidc settings from keycloak.
settings = get_oidc_settings(client_id)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
json={
"name": "oidc.basepath.test",
"config": {
"ssoEnabled": True,
"ssoType": "oidc",
"oidcConfig": {
"clientId": settings["client_id"],
"clientSecret": settings["client_secret"],
# Change the hostname of the issuer to the internal resolvable hostname of the idp
"issuer": f"{idp.container.container_configs['6060'].get(urlparse(settings['issuer']).path)}",
"issuerAlias": settings["issuer"],
"getUserInfo": True,
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
def test_oidc_authn(
signoz: SigNoz,
idp: TestContainerIDP,
driver: webdriver.Chrome,
create_user_idp: Callable[[str, str, bool, str, str], None],
idp_login: Callable[[str, str], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], dict],
) -> None:
"""
Tests the OIDC authn flow when SigNoz is served under a base path. The login
URL the backend produces (and thus the IdP callback) carries the /signoz
prefix; the e2e browser login must complete and create the user.
"""
# Create a user in the idp.
create_user_idp("viewer@oidc.basepath.test", "password123", True)
# Get the session context from signoz which will give the OIDC login URL.
session_context = get_session_context("viewer@oidc.basepath.test")
assert len(session_context["orgs"]) == 1
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
# change the url to the external resolvable hostname of the idp
parsed_url = urlparse(url)
actual_url = f"{idp.container.host_configs['6060'].get(parsed_url.path)}?{parsed_url.query}"
driver.get(actual_url)
idp_login("viewer@oidc.basepath.test", "password123")
# Assert that the user was created in signoz (lookup under the base path).
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
users = requests.get(
signoz.self.host_configs["8080"].get("/signoz/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert users.status_code == HTTPStatus.OK, users.text
user = next((u for u in users.json()["data"] if u["email"] == "viewer@oidc.basepath.test"), None)
assert user is not None, "User with email 'viewer@oidc.basepath.test' not found"
user_with_roles = requests.get(
signoz.self.host_configs["8080"].get(f"/signoz/api/v2/users/{user['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert user_with_roles.status_code == HTTPStatus.OK, user_with_roles.text
assert_user_has_role(user_with_roles.json()["data"], "signoz-viewer")

View File

@@ -1,117 +0,0 @@
from collections.abc import Callable
from http import HTTPStatus
import requests
from selenium import webdriver
from wiremock.resources.mappings import Mapping
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD, add_license, assert_user_has_role
from fixtures.types import Operation, SigNoz, TestContainerDocker, TestContainerIDP
# SigNoz is served under /signoz, so the SAML ACS registered with the IdP must
# include the prefix to match the backend-generated AssertionConsumerServiceURL.
BASE_PATH = "/signoz"
SAML_CALLBACK_PATH = f"{BASE_PATH}/api/v1/complete/saml"
def test_apply_license(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
) -> None:
"""
Applies a license to the signoz instance. add_license is a plain function
called from the test (function scope), so the function-scoped make_http_mocks
fixture is safe to use; base_path prefixes the licensing API call.
"""
add_license(signoz, make_http_mocks, get_token, base_path=BASE_PATH)
def test_create_auth_domain(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
create_saml_client: Callable[[str, str], None],
get_saml_settings: Callable[[], dict],
get_token: Callable[[str, str], str],
) -> None:
"""
Creates a SAML auth domain in SigNoz served under a base path. The ACS
registered with the IdP carries the /signoz prefix.
"""
# Create a saml client in the idp with the prefixed ACS.
create_saml_client("saml.basepath.test", SAML_CALLBACK_PATH)
# Get the saml settings from keycloak.
settings = get_saml_settings()
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = requests.post(
signoz.self.host_configs["8080"].get("/signoz/api/v1/domains"),
json={
"name": "saml.basepath.test",
"config": {
"ssoEnabled": True,
"ssoType": "saml",
"samlConfig": {
"samlEntity": settings["entityID"],
"samlIdp": settings["singleSignOnServiceLocation"],
"samlCert": settings["certificate"],
},
},
},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.CREATED
def test_saml_authn(
signoz: SigNoz,
idp: TestContainerIDP, # pylint: disable=unused-argument
driver: webdriver.Chrome,
create_user_idp: Callable[[str, str, bool, str, str], None],
idp_login: Callable[[str, str], None],
get_token: Callable[[str, str], str],
get_session_context: Callable[[str], dict],
) -> None:
"""
Tests the SAML authn flow when SigNoz is served under a base path. The
AssertionConsumerServiceURL in the AuthnRequest carries the /signoz prefix;
the e2e browser login must complete and create the user.
"""
# Create a user in the idp.
create_user_idp("viewer@saml.basepath.test", "password", True)
# Get the session context from signoz which will give the SAML login URL.
session_context = get_session_context("viewer@saml.basepath.test")
assert len(session_context["orgs"]) == 1
assert len(session_context["orgs"][0]["authNSupport"]["callback"]) == 1
url = session_context["orgs"][0]["authNSupport"]["callback"][0]["url"]
driver.get(url)
idp_login("viewer@saml.basepath.test", "password")
# Assert that the user was created in signoz (lookup under the base path).
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
users = requests.get(
signoz.self.host_configs["8080"].get("/signoz/api/v2/users"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert users.status_code == HTTPStatus.OK, users.text
user = next((u for u in users.json()["data"] if u["email"] == "viewer@saml.basepath.test"), None)
assert user is not None, "User with email 'viewer@saml.basepath.test' not found"
user_with_roles = requests.get(
signoz.self.host_configs["8080"].get(f"/signoz/api/v2/users/{user['id']}"),
headers={"Authorization": f"Bearer {admin_token}"},
timeout=5,
)
assert user_with_roles.status_code == HTTPStatus.OK, user_with_roles.text
assert_user_has_role(user_with_roles.json()["data"], "signoz-viewer")

View File

@@ -1,57 +0,0 @@
from collections.abc import Callable
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.auth import register_admin, session_context_getter, token_getter
from fixtures.signoz import create_signoz
# SigNoz is served under this URL path prefix for the base-path suite. The auth
# helpers from fixtures/auth.py are reused via their factories with this prefix,
# so these fixtures shadow the same-named root ones without duplicating logic.
# Only the path component is read by global.ExternalPath(), which derives the
# http.StripPrefix route prefix.
BASE_PATH = "/signoz"
@pytest.fixture(name="signoz", scope="package")
def signoz_base_path( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped SigNoz served under BASE_PATH. Sets SIGNOZ_GLOBAL_EXTERNAL__URL
with the prefix so the backend derives the http.StripPrefix route prefix.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_base_path",
env_overrides={"SIGNOZ_GLOBAL_EXTERNAL__URL": f"http://localhost:8080{BASE_PATH}"},
)
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin_base_path(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_base_path", base_path=BASE_PATH)
@pytest.fixture(name="get_token", scope="function")
def get_token(signoz: types.SigNoz) -> Callable[[str, str], str]:
return token_getter(signoz, BASE_PATH)
@pytest.fixture(name="get_session_context", scope="function")
def get_session_context(signoz: types.SigNoz) -> Callable[[str], dict]:
return session_context_getter(signoz, BASE_PATH)

View File

@@ -483,7 +483,7 @@ def test_enable_metrics_provisions_dashboards(
assert isinstance(dashboards_in_service, list) and len(dashboards_in_service) > 0, "assets.dashboards should be non-empty after enabling metrics"
provisioned_ids = set()
for dash in dashboards_in_service:
assert "integrationDashboard" in dash, "Integration dashboard entry missing"
assert "integrationDashboard" in dash, f"Integration dashboard entry missing"
try:
uuid.UUID(dash["integrationDashboard"]["id"])
except ValueError as err: