Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions ui/apps/pmm/src/sep/SepAuthGate.messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const Messages = {
loading: 'Authenticating with Smart Expert Platform…',
retry: 'Try again',
// Shown instead of the page: the exchange failed at load, so there is no work
// in progress to preserve.
blocked: {
signedOutTitle: 'Not signed in',
signedOut:
'Smart Expert Platform could not verify your PMM session. Sign in to PMM again, then retry.',
unreachableTitle: 'Could not reach Smart Expert Platform',
unreachable:
'Authenticating with Smart Expert Platform failed. This is usually temporary.',
},
// Shown beside a page that is already open. Never replaces it — the user may
// be part-way through a form.
notice: {
signedOut:
'Your PMM session has ended, so Smart Expert Platform can no longer be reached. Anything you submit from this page will fail. Sign in to PMM in another tab, then retry — your work here is kept.',
unreachable:
'Lost the connection to Smart Expert Platform. Anything you submit from this page will fail until it is back. Your work here is kept.',
},
};
150 changes: 150 additions & 0 deletions ui/apps/pmm/src/sep/SepAuthGate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { ApiError, postSessionExchange, setTokenMinter } from '@sep/api';
import { SepAuthGate } from './SepAuthGate';
import { initSepAuth } from './bootstrap';
import { markSepSignedOut, resetSepAuthStore } from './sepTokenStore';

vi.mock('@sep/api', async (importOriginal) => ({
...(await importOriginal<typeof import('@sep/api')>()),
postSessionExchange: vi.fn(),
}));

const exchange = vi.mocked(postSessionExchange);

const bearer = (accessToken = 'bearer-1') => ({
access_token: accessToken,
expires_in: 300,
});

const unauthorized = () =>
new ApiError({ kind: 'http', status: 401, message: 'no session' });

const renderGate = () =>
render(
<SepAuthGate>
<div>plugin content</div>
</SepAuthGate>
);

/** A page with unsaved input, standing in for a half-filled plugin form. */
const renderGateWithForm = () =>
render(
<SepAuthGate>
<input aria-label="target" defaultValue="" />
</SepAuthGate>
);

beforeEach(() => {
exchange.mockReset();
resetSepAuthStore();
initSepAuth();
});

afterEach(() => {
resetSepAuthStore();
setTokenMinter(null);
});

describe('SepAuthGate — bootstrap', () => {
it('withholds children until the exchange resolves', async () => {
let resolveExchange: (value: ReturnType<typeof bearer>) => void = () => {};
exchange.mockReturnValue(
new Promise((resolve) => {
resolveExchange = resolve;
})
);

renderGate();

expect(screen.queryByText('plugin content')).not.toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();

resolveExchange(bearer());

expect(await screen.findByText('plugin content')).toBeInTheDocument();
});

it('renders children once a bearer is held', async () => {
exchange.mockResolvedValue(bearer());

renderGate();

expect(await screen.findByText('plugin content')).toBeInTheDocument();
expect(exchange).toHaveBeenCalledOnce();
});

it('shows a signed-out page instead of the plugin, and does not loop', async () => {
exchange.mockRejectedValue(unauthorized());

renderGate();

expect(await screen.findByTestId('sep-auth-error')).toHaveTextContent(
'Not signed in'
);
expect(screen.queryByText('plugin content')).not.toBeInTheDocument();
expect(exchange).toHaveBeenCalledOnce();
});

it('distinguishes an unreachable SEP from a rejected session', async () => {
exchange.mockRejectedValue(new Error('network down'));

renderGate();

expect(await screen.findByTestId('sep-auth-error')).toHaveTextContent(
'Could not reach Smart Expert Platform'
);
});

it('exchanges again when the user retries', async () => {
exchange.mockRejectedValue(unauthorized());
renderGate();
await screen.findByTestId('sep-auth-error');

exchange.mockResolvedValue(bearer());
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));

expect(await screen.findByText('plugin content')).toBeInTheDocument();
expect(exchange).toHaveBeenCalledTimes(2);
});
});

describe('SepAuthGate — failure on a mounted page', () => {
it('reports a rejected session without unmounting the page', async () => {
exchange.mockResolvedValue(bearer());
renderGate();
await screen.findByText('plugin content');

act(() => markSepSignedOut());

expect(screen.getByTestId('sep-auth-notice')).toBeInTheDocument();
expect(screen.getByText('plugin content')).toBeInTheDocument();
expect(screen.queryByTestId('sep-auth-error')).not.toBeInTheDocument();
});

it('preserves in-progress form state', async () => {
exchange.mockResolvedValue(bearer());
renderGateWithForm();
const field = await screen.findByLabelText('target');
fireEvent.change(field, { target: { value: 'half-written command' } });

act(() => markSepSignedOut());

expect(screen.getByTestId('sep-auth-notice')).toBeInTheDocument();
expect(screen.getByLabelText('target')).toHaveValue('half-written command');
});

it('clears the notice when the retry succeeds, keeping the page throughout', async () => {
exchange.mockResolvedValue(bearer());
renderGateWithForm();
const field = await screen.findByLabelText('target');
fireEvent.change(field, { target: { value: 'half-written command' } });
act(() => markSepSignedOut());

exchange.mockResolvedValue(bearer('bearer-2'));
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));

await screen.findByLabelText('target');
expect(screen.queryByTestId('sep-auth-notice')).not.toBeInTheDocument();
expect(screen.getByLabelText('target')).toHaveValue('half-written command');
});
});
117 changes: 117 additions & 0 deletions ui/apps/pmm/src/sep/SepAuthGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { FC, PropsWithChildren, useEffect, useSyncExternalStore } from 'react';
import {
Alert,
AlertTitle,
Box,
Button,
CircularProgress,
} from '@mui/material';
import { Messages } from './SepAuthGate.messages';
import {
type SepAuthNotice,
ensureSepToken,
getSepAuthState,
retrySepAuth,
subscribeSepAuth,
} from './sepTokenStore';

const RetryButton: FC = () => (
<Button
color="inherit"
size="small"
onClick={() => {
void retrySepAuth();
}}
>
{Messages.retry}
</Button>
);

/**
* Inline report of a failure that arrived after the page was already open.
*
* Deliberately not a replacement for the page: a background renewal failing
* must not discard a half-filled form. It tells the user that submitting will
* fail and offers a retry, and leaves everything else alone.
*/
const SepAuthNoticeBar: FC<{ kind: SepAuthNotice }> = ({ kind }) => (
<Alert
severity="warning"
data-testid="sep-auth-notice"
action={<RetryButton />}
>
{kind === 'signedOut'
? Messages.notice.signedOut
: Messages.notice.unreachable}
</Alert>
);

/**
* Holds a SEP route until a SEP bearer has been minted from the PMM session.
*
* Gating here rather than exchanging at app startup keeps SEP out of the boot
* path for the PMM users who never open a SEP page — the UI has no
* `PMM_ENABLE_SEP` flag to check, so an eager exchange would hit SEP on every
* page load for everybody.
*
* It also removes a race the token provider cannot: `setTokenProvider` is
* synchronous, so a plugin's first queries would otherwise fire before the
* exchange resolves and 401 on arrival. Children do not render until a bearer
* is in hand.
*
* Once they have rendered they stay rendered. A later failure is reported by
* `notice`, beside the page rather than instead of it.
*/
export const SepAuthGate: FC<PropsWithChildren> = ({ children }) => {
const { phase, notice } = useSyncExternalStore(
subscribeSepAuth,
getSepAuthState
);

useEffect(() => {
// No-ops when a bearer is already held or the session was rejected; a
// previous transient failure is retried on the next visit to a SEP route.
void ensureSepToken();
}, []);

if (phase === 'ready') {
return (
<>
{notice !== null && <SepAuthNoticeBar kind={notice} />}
{children}
</>
);
}

if (phase === 'signedOut' || phase === 'unreachable') {
const signedOut = phase === 'signedOut';
return (
<Alert
severity={signedOut ? 'warning' : 'error'}
data-testid="sep-auth-error"
action={<RetryButton />}
>
<AlertTitle>
{signedOut
? Messages.blocked.signedOutTitle
: Messages.blocked.unreachableTitle}
</AlertTitle>
{signedOut ? Messages.blocked.signedOut : Messages.blocked.unreachable}
</Alert>
);
}

return (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 4,
}}
>
<CircularProgress aria-label={Messages.loading} />
</Box>
);
};
8 changes: 7 additions & 1 deletion ui/apps/pmm/src/sep/SepPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Stack from '@mui/material/Stack';
import { Page } from 'components/page';
import { useUser } from 'contexts/user';
import { OrgRole } from 'types/user.types';
import { SepAuthGate } from './SepAuthGate';

/**
* Shared container for SEP apps mounted as native PMM routes.
Expand All @@ -19,6 +20,9 @@ import { OrgRole } from 'types/user.types';
* `isPMMAdmin` is `isGrafanaAdmin || orgRole === Admin`, and `roles` (org-role
* only) cannot express the Grafana-admin half on its own, so it gates the
* remaining case and Page renders its standard unauthorized card.
*
* `SepAuthGate` sits inside that check, so the SEP session exchange only runs
* for a user who is allowed on the page in the first place.
*/
export const SepPage: FC<PropsWithChildren> = ({ children }) => {
const { user } = useUser();
Expand All @@ -29,7 +33,9 @@ export const SepPage: FC<PropsWithChildren> = ({ children }) => {
roles={user?.isPMMAdmin ? undefined : [OrgRole.Admin]}
>
<Stack gap={3} sx={{ flex: 1 }}>
<div>{children}</div>
<SepAuthGate>
<div>{children}</div>
</SepAuthGate>
</Stack>
</Page>
);
Expand Down
41 changes: 28 additions & 13 deletions ui/apps/pmm/src/sep/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import { setTokenProvider, setOnUnauthorized } from '@sep/api';
import {
setOnRefreshed,
setOnUnauthorized,
setTokenMinter,
setTokenProvider,
} from '@sep/api';
import {
getSepToken,
markSepSignedOut,
mintSepToken,
recordSepToken,
} from './sepTokenStore';

/**
* Interim SEP auth wiring (migration Option D).
* SEP auth wiring for the embedded UI.
*
* SEP's axios client delegates the bearer token via `setTokenProvider`. During the
* migration the PMM dev proxy injects `PMM_DEV_SEP_INTERNAL_TOKEN` server-side,
* so the browser sends no token — the provider returns `null`. `setOnUnauthorized` is a
* no-op because there is no SEP login flow to redirect to (PMM owns the session).
* PMM owns the session, so SEP is authenticated as the actual PMM user by
* exchanging the `pmm_session` cookie for a short-lived SEP bearer
* (`POST /api/oauth/session/exchange`, SEP-1692) rather than by logging in.
* This replaces the interim wiring in which the dev proxy injected
* `PMM_DEV_SEP_INTERNAL_TOKEN` server-side: that authenticated as SEP's
* internal service principal, which hardcodes `is_admin = False`, so every
* admin-gated SEP surface answered 403.
*
* This is replaced by the token-exchange provider (Option B), which calls
* `postSessionExchange()` (`POST /api/oauth/session/exchange`, SEP-1692) to trade
* PMM's session cookie for a short-lived SEP bearer, at which point `isAdmin` also
* comes from the token's role claim rather than the internal token's service
* principal, which hardcodes `is_admin = False`.
* Registration is side-effect free — no network call happens here. The first
* exchange is triggered by `SepAuthGate` when a SEP route mounts, so PMM users
* who never open one never talk to SEP. State and lifetime live in
* `./sepTokenStore`.
*/
export const initSepAuth = () => {
setTokenProvider(() => null);
setOnUnauthorized(() => {});
setTokenProvider(getSepToken);
setTokenMinter(mintSepToken);
setOnRefreshed(recordSepToken);
setOnUnauthorized(markSepSignedOut);
};
Loading
Loading