diff --git a/FORK.md b/FORK.md new file mode 100644 index 0000000000..d4bfa48927 --- /dev/null +++ b/FORK.md @@ -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 +``` diff --git a/packages/api/src/api-app.ts b/packages/api/src/api-app.ts index 5f4c3181d4..bbcd38e0c6 100644 --- a/packages/api/src/api-app.ts +++ b/packages/api/src/api-app.ts @@ -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'; @@ -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, +); app.use('/saved-search', isUserAuthenticated, savedSearchRouter); app.use('/favorites', isUserAuthenticated, favoritesRouter); app.use('/pinned-filters', isUserAuthenticated, pinnedFiltersRouter); diff --git a/packages/api/src/config.ts b/packages/api/src/config.ts index 7c6a242969..f312b734d0 100644 --- a/packages/api/src/config.ts +++ b/packages/api/src/config.ts @@ -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; diff --git a/packages/api/src/middleware/auth.ts b/packages/api/src/middleware/auth.ts index 442be3fa25..ee6a578440 100644 --- a/packages/api/src/middleware/auth.ts +++ b/packages/api/src/middleware/auth.ts @@ -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 { @@ -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); +} + 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 @@ -124,6 +157,7 @@ export function isUserAuthenticated( email: 'local-user@hyperdx.io', // @ts-ignore team: '_local_team_', + role: 'admin', }; setBusinessContext({ teamId: '_local_team_', diff --git a/packages/api/src/models/user.ts b/packages/api/src/models/user.ts index 8f60fadeb5..f37aa2ccc3 100644 --- a/packages/api/src/models/user.ts +++ b/packages/api/src/models/user.ts @@ -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 { @@ -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; @@ -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() { diff --git a/packages/api/src/routers/api/me.ts b/packages/api/src/routers/api/me.ts index b574928528..596f75a141 100644 --- a/packages/api/src/routers/api/me.ts +++ b/packages/api/src/routers/api/me.ts @@ -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'; @@ -34,6 +35,7 @@ router.get('/', async (req, res: express.Response, next) => { email, id, name, + role: getEffectiveUserRole(req.user), team, usageStatsEnabled: USAGE_STATS_ENABLED, aiAssistantEnabled: !!(AI_API_KEY || ANTHROPIC_API_KEY), diff --git a/packages/api/src/routers/api/root.ts b/packages/api/src/routers/api/root.ts index ebb3d38eff..58b12a0116 100644 --- a/packages/api/src/routers/api/root.ts +++ b/packages/api/src/routers/api/root.ts @@ -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 @@ -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 @@ -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) => { diff --git a/packages/api/src/utils/__tests__/roles.test.ts b/packages/api/src/utils/__tests__/roles.test.ts new file mode 100644 index 0000000000..b64c90c224 --- /dev/null +++ b/packages/api/src/utils/__tests__/roles.test.ts @@ -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; + }); +}); diff --git a/packages/api/src/utils/roles.ts b/packages/api/src/utils/roles.ts new file mode 100644 index 0000000000..1d8c9067d7 --- /dev/null +++ b/packages/api/src/utils/roles.ts @@ -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 { + 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'; +} diff --git a/packages/app/src/Spotlights.tsx b/packages/app/src/Spotlights.tsx index bbb593d651..6eab3e78f2 100644 --- a/packages/app/src/Spotlights.tsx +++ b/packages/app/src/Spotlights.tsx @@ -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'; @@ -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(); @@ -171,16 +173,19 @@ export const useSpotlightActions = () => { router.push('/services'); }, }, - { - id: 'team-settings', - group: 'Menu', - leftSection: , - label: 'Team Settings', - - onClick: () => { - router.push('/team'); - }, - }, + ...(isAdmin + ? [ + { + id: 'team-settings', + group: 'Menu', + leftSection: , + label: 'Team Settings', + onClick: () => { + router.push('/team'); + }, + } satisfies SpotlightActionData, + ] + : []), { id: 'documentation', group: 'Menu', @@ -209,7 +214,7 @@ export const useSpotlightActions = () => { ); return logViewActions; - }, [brandName, logomark, logViewsData, dashboardsData, router]); + }, [brandName, isAdmin, logomark, logViewsData, dashboardsData, router]); return { actions }; }; diff --git a/packages/app/src/TeamPage.tsx b/packages/app/src/TeamPage.tsx index 19cd5e98f8..fdbf6a91d1 100644 --- a/packages/app/src/TeamPage.tsx +++ b/packages/app/src/TeamPage.tsx @@ -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'; @@ -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]); + const [isEditingTeamName, setIsEditingTeamName] = useState(false); const form = useForm<{ name: string }>({ defaultValues: { name: team?.name }, diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx index 9a9a877c38..acc4da64f7 100644 --- a/packages/app/src/components/AppNav/AppNav.tsx +++ b/packages/app/src/components/AppNav/AppNav.tsx @@ -29,6 +29,7 @@ import { import api from '@/api'; import { AlertStatusIcon } from '@/components/AlertStatusIcon'; +import { useIsAdmin } from '@/hooks/useIsAdmin'; import { IS_LOCAL_MODE } from '@/config'; import { Dashboard, useDashboards } from '@/dashboard'; import { useFavorites } from '@/favorites'; @@ -173,6 +174,7 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { const { pathname, query } = router; const { data: meData } = api.useMe(); + const isAdmin = useIsAdmin(); const { width } = useWindowSize(); @@ -467,8 +469,8 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { {/* Feedback */} - {/* Team Settings (Cloud only) */} - {!IS_LOCAL_MODE && ( + {/* Team Settings β€” HPCNT: admins only (also hidden in local mode) */} + {!IS_LOCAL_MODE && isAdmin && ( { export const meHandler = makeHandler('/me', { name: 'Mister Test', + role: 'admin', team: { name: 'Test.io', }, diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 78c0711211..6c4d1a8e3f 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -2258,6 +2258,8 @@ export const MeApiResponseSchema = z.object({ email: z.string(), id: z.string(), name: z.string(), + /** HPCNT fork: admin | viewer */ + role: z.enum(['admin', 'viewer']).optional(), team: TeamSchema.pick({ id: true, name: true,