Skip to content
Open
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
57 changes: 57 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Hyperconnect HyperDX fork

Upstream: [hyperdxio/hyperdx](https://github.com/hyperdxio/hyperdx)

## HPCNT changes

### Viewer / Admin roles

| Role | Can |
|------|-----|
| `admin` | Sources, connections, team settings, invites, alerts, webhooks mutations |
| `viewer` | Log search / ClickHouse proxy / dashboards / saved searches (read+personal writes) |

**Assignment**

- First user who registers a team → always `admin`
- Invited users → `viewer`, unless email is listed in `ADMIN_EMAILS`
- Legacy users with no `role` field → treated as `admin` (no lockout)
- `IS_LOCAL_APP_MODE` → injected user is `admin` (auth still off)

**Env**

```bash
# Comma-separated, case-insensitive
ADMIN_EMAILS=roa@hpcnt.com,sre-oncall@hpcnt.com
```

**API**

- `GET /api/me` includes `role: "admin" | "viewer"`
- Mutations on `/sources`, `/connections`, `/team`, `/alerts`, `/webhooks` return `403 { error: "adminRequired" }` for viewers

**UI**

- Team Settings nav hidden for viewers
- `/team` redirects viewers to `/search`

### Deploy note

Roles require **auth on** (local app mode off). With local mode, everyone is effectively admin.

Suggested ClickStack values:

```yaml
hyperdx:
config:
FRONTEND_URL: "https://hyperdx.prod.kube-uw2.hpcnt.com"
ADMIN_EMAILS: "you@hpcnt.com"
# remove IS_LOCAL_APP_MODE / entry-noauth once auth is enabled
```

## Sync upstream

```bash
git fetch upstream
git merge upstream/main # or rebase
```
41 changes: 35 additions & 6 deletions packages/api/src/api-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import onHeaders from 'on-headers';

import * as config from './config';
import mcpRouter from './mcp/app';
import { isUserAuthenticated } from './middleware/auth';
import {
isUserAuthenticated,
requireAdminForMutations,
} from './middleware/auth';
import defaultCors from './middleware/cors';
import { appErrorHandler } from './middleware/error';
import routers from './routers/api';
Expand Down Expand Up @@ -97,13 +100,39 @@ app.use('/mcp', mcpRouter);

// PRIVATE ROUTES
app.use('/ai', isUserAuthenticated, routers.aiRouter);
app.use('/alerts', isUserAuthenticated, routers.alertsRouter);
// HPCNT fork: viewers may read alerts/team/sources; only admins mutate settings.
app.use(
'/alerts',
isUserAuthenticated,
requireAdminForMutations,
routers.alertsRouter,
);
app.use('/dashboards', isUserAuthenticated, routers.dashboardRouter);
app.use('/me', isUserAuthenticated, routers.meRouter);
app.use('/team', isUserAuthenticated, routers.teamRouter);
app.use('/webhooks', isUserAuthenticated, routers.webhooksRouter);
app.use('/connections', isUserAuthenticated, connectionsRouter);
app.use('/sources', isUserAuthenticated, sourcesRouter);
app.use(
'/team',
isUserAuthenticated,
requireAdminForMutations,
routers.teamRouter,
);
app.use(
'/webhooks',
isUserAuthenticated,
requireAdminForMutations,
routers.webhooksRouter,
);
app.use(
'/connections',
isUserAuthenticated,
requireAdminForMutations,
connectionsRouter,
);
app.use(
'/sources',
isUserAuthenticated,
requireAdminForMutations,
sourcesRouter,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

External API skips role checks

High Severity

Session routes for /sources, /connections, /team, /alerts, and /webhooks now use requireAdminForMutations, but /api/v2 still authenticates with access keys only. Viewers receive an accessKey from /me and can mutate those resources through v2 while the UI returns 403 on the internal routes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a4a1c5f. Configure here.

app.use('/saved-search', isUserAuthenticated, savedSearchRouter);
app.use('/favorites', isUserAuthenticated, favoritesRouter);
app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter);
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ export const RUN_SCHEDULED_TASKS_EXTERNALLY =
export const IS_LOCAL_APP_MODE =
env.IS_LOCAL_APP_MODE === 'DANGEROUSLY_is_local_app_mode💀';

/**
* HPCNT fork: comma-separated emails that receive `admin` role on register/invite.
* Everyone else gets `viewer` (log explore only). First team registrant is always admin.
*/
export const ADMIN_EMAILS = env.ADMIN_EMAILS ?? '';

// Only used to bootstrap empty instances
export const DEFAULT_CONNECTIONS = env.DEFAULT_CONNECTIONS;
export const DEFAULT_SOURCES = env.DEFAULT_SOURCES;
Expand Down
34 changes: 34 additions & 0 deletions packages/api/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
setBusinessContext,
} from '@/utils/instrumentation';
import logger from '@/utils/logger';
import { isUserRole, type UserRole } from '@/utils/roles';

declare global {
namespace Express {
Expand All @@ -29,6 +30,38 @@ declare module 'express-session' {
}
}

/** Effective role: missing/legacy → admin (don't lock out existing installs). */
export function getEffectiveUserRole(user: {
role?: UserRole | string | null;
}): UserRole {
if (isUserRole(user.role)) {
return user.role;
}
return 'admin';
}

export function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
return res.sendStatus(401);
}
if (getEffectiveUserRole(req.user) !== 'admin') {
return res.status(403).json({ error: 'adminRequired' });
}
return next();
}

/** Block non-safe HTTP methods for viewers (settings mutations). */
export function requireAdminForMutations(
req: Request,
res: Response,
next: NextFunction,
) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
return requireAdmin(req, res, next);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Viewers read team secrets via GET

High Severity

Mounting requireAdminForMutations on /team lets viewers pass all GET requests. GET /team returns the team apiKey, and GET /team/invitations returns invite URLs built from secret tokens—data meant for admins, not log-only viewers.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a4a1c5f. Configure here.


export function redirectToDashboard(req: Request, res: Response) {
// Use 303 See Other so browsers always follow the redirect with GET, even
// when the original request was a POST (e.g. /login/password). Without an
Expand Down Expand Up @@ -124,6 +157,7 @@ export function isUserAuthenticated(
email: 'local-user@hyperdx.io',
// @ts-ignore
team: '_local_team_',
role: 'admin',
};
setBusinessContext({
teamId: '_local_team_',
Expand Down
11 changes: 11 additions & 0 deletions packages/api/src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import passportLocalMongoose from '@hyperdx/passport-local-mongoose';
import mongoose, { Schema } from 'mongoose';
import { v4 as uuidv4 } from 'uuid';

import type { UserRole } from '@/utils/roles';

type ObjectId = mongoose.Types.ObjectId;

export interface IUser {
Expand All @@ -12,6 +14,8 @@ export interface IUser {
email: string;
name: string;
team: ObjectId;
/** HPCNT fork: admin can mutate sources/team; viewer is log-explorer only. */
role: UserRole;
}

export type UserDocument = mongoose.HydratedDocument<IUser>;
Expand All @@ -24,6 +28,13 @@ const UserSchema = new Schema(
required: true,
},
team: { type: mongoose.Schema.Types.ObjectId, ref: 'Team' },
role: {
type: String,
enum: ['admin', 'viewer'],
// Existing users without role are treated as admin in middleware for
// backwards compatibility; new invites default to viewer.
default: 'viewer',
},
accessKey: {
type: String,
default: function genUUID() {
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/routers/api/me.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import express from 'express';

import { AI_API_KEY, ANTHROPIC_API_KEY, USAGE_STATS_ENABLED } from '@/config';
import { getTeam } from '@/controllers/team';
import { getEffectiveUserRole } from '@/middleware/auth';
import { Api404Error } from '@/utils/errors';
import { sendJson } from '@/utils/serialization';

Expand Down Expand Up @@ -34,6 +35,7 @@ router.get('/', async (req, res: express.Response<MeApiResponse>, next) => {
email,
id,
name,
role: getEffectiveUserRole(req.user),
team,
usageStatsEnabled: USAGE_STATS_ENABLED,
aiAssistantEnabled: !!(AI_API_KEY || ANTHROPIC_API_KEY),
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/routers/api/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import User from '@/models/user'; // TODO -> do not import model directly
import { setupTeamDefaults } from '@/setupDefaults';
import logger from '@/utils/logger';
import passport from '@/utils/passport';
import { resolveUserRole } from '@/utils/roles';
import { passwordSchema, validatePassword } from '@/utils/validators';

const registrationSchema = z
Expand Down Expand Up @@ -91,6 +92,8 @@ router.post(
});
user.team = team._id;
user.name = email;
// First registrant is always admin (single-team OSS bootstrap).
user.role = 'admin';
await user.save();

// Set up default connections and sources for this new team
Expand Down Expand Up @@ -156,6 +159,7 @@ router.post('/team/setup/:token', async (req, res, next) => {
email: teamInvite.email,
name: teamInvite.email,
team: teamInvite.teamId,
role: resolveUserRole(teamInvite.email),
}),
password,
async (err: Error, user: any) => {
Expand Down
31 changes: 31 additions & 0 deletions packages/api/src/utils/__tests__/roles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';

import {
isUserRole,
parseAdminEmails,
resolveUserRole,
} from '@/utils/roles';

describe('roles', () => {
it('parseAdminEmails splits and lowercases', () => {
expect(parseAdminEmails('A@B.com, c@d.com ')).toEqual(
new Set(['a@b.com', 'c@d.com']),
);
expect(parseAdminEmails(undefined).size).toBe(0);
});

it('isUserRole', () => {
expect(isUserRole('admin')).toBe(true);
expect(isUserRole('viewer')).toBe(true);
expect(isUserRole('other')).toBe(false);
});

it('resolveUserRole uses ADMIN_EMAILS', () => {
const prev = process.env.ADMIN_EMAILS;
process.env.ADMIN_EMAILS = 'Admin@hpcnt.com';
expect(resolveUserRole('admin@hpcnt.com')).toBe('admin');
expect(resolveUserRole('other@hpcnt.com')).toBe('viewer');
expect(resolveUserRole(null)).toBe('viewer');
process.env.ADMIN_EMAILS = prev;
});
});
27 changes: 27 additions & 0 deletions packages/api/src/utils/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type UserRole = 'admin' | 'viewer';

export const USER_ROLES: UserRole[] = ['admin', 'viewer'];

export function isUserRole(value: unknown): value is UserRole {
return value === 'admin' || value === 'viewer';
}

/** Comma-separated emails that should always be admin (case-insensitive). */
export function parseAdminEmails(raw: string | undefined): Set<string> {
if (!raw) return new Set();
return new Set(
raw
.split(',')
.map(e => e.trim().toLowerCase())
.filter(Boolean),
);
}

export function resolveUserRole(email: string | undefined | null): UserRole {
if (!email) return 'viewer';
const admins = parseAdminEmails(process.env.ADMIN_EMAILS);
if (admins.has(email.toLowerCase())) {
return 'admin';
}
return 'viewer';
}
27 changes: 16 additions & 11 deletions packages/app/src/Spotlights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { useBrandDisplayName, useLogomark } from './theme/ThemeProvider';
import { IS_K8S_DASHBOARD_ENABLED } from './config';
import { useDashboards } from './dashboard';
import { useIsAdmin } from './hooks/useIsAdmin';
import { useSavedSearches } from './savedSearch';

import '@mantine/spotlight/styles.css';
Expand All @@ -25,6 +26,7 @@ export const useSpotlightActions = () => {
const router = useRouter();
const brandName = useBrandDisplayName();
const logomark = useLogomark({ size: 16 });
const isAdmin = useIsAdmin();

const { data: logViewsData } = useSavedSearches();
const { data: dashboardsData } = useDashboards();
Expand Down Expand Up @@ -171,16 +173,19 @@ export const useSpotlightActions = () => {
router.push('/services');
},
},
{
id: 'team-settings',
group: 'Menu',
leftSection: <IconSettings size={16} />,
label: 'Team Settings',

onClick: () => {
router.push('/team');
},
},
...(isAdmin
? [
{
id: 'team-settings',
group: 'Menu',
leftSection: <IconSettings size={16} />,
label: 'Team Settings',
onClick: () => {
router.push('/team');
},
} satisfies SpotlightActionData,
]
: []),
{
id: 'documentation',
group: 'Menu',
Expand Down Expand Up @@ -209,7 +214,7 @@ export const useSpotlightActions = () => {
);

return logViewActions;
}, [brandName, logomark, logViewsData, dashboardsData, router]);
}, [brandName, isAdmin, logomark, logViewsData, dashboardsData, router]);

return { actions };
};
Expand Down
10 changes: 9 additions & 1 deletion packages/app/src/TeamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import SecurityPoliciesSection from './components/TeamSettings/SecurityPoliciesS
import SourcesSection from './components/TeamSettings/SourcesSection';
import TeamMembersSection from './components/TeamSettings/TeamMembersSection';
import TeamQueryConfigSection from './components/TeamSettings/TeamQueryConfigSection';
import { useIsAdmin } from './hooks/useIsAdmin';
import { useBrandDisplayName } from './theme/ThemeProvider';
import api from './api';
import { withAppNav } from './layout';
Expand Down Expand Up @@ -58,7 +59,14 @@ export default function TeamPage() {
const allowedAuthMethods = team?.allowedAuthMethods ?? [];
const hasAllowedAuthMethods = allowedAuthMethods.length > 0;

const hasAdminAccess = true;
const hasAdminAccess = useIsAdmin();

useEffect(() => {
if (!hasAdminAccess) {
void router.replace('/search');
}
}, [hasAdminAccess, router]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Team page shows settings to viewers

High Severity

After switching hasAdminAccess to useIsAdmin(), only the team-name edit control is gated. Tabs still render all Team Settings sections (sources, connections, members, etc.) whenever team is loaded, while the redirect runs in useEffect. Viewers who open /team can see admin UI and trigger those fetches before navigation finishes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a4a1c5f. Configure here.

const [isEditingTeamName, setIsEditingTeamName] = useState(false);
const form = useForm<{ name: string }>({
defaultValues: { name: team?.name },
Expand Down
Loading