-
Notifications
You must be signed in to change notification settings - Fork 0
feat: Admin/Viewer 역할 분리 (설정 vs 로그 탐색) #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Viewers read team secrets via GETHigh Severity Mounting 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 | ||
|
|
@@ -124,6 +157,7 @@ export function isUserAuthenticated( | |
| email: 'local-user@hyperdx.io', | ||
| // @ts-ignore | ||
| team: '_local_team_', | ||
| role: 'admin', | ||
| }; | ||
| setBusinessContext({ | ||
| teamId: '_local_team_', | ||
|
|
||
| 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; | ||
| }); | ||
| }); |
| 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'; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]); | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Team page shows settings to viewersHigh Severity After switching Reviewed by Cursor Bugbot for commit a4a1c5f. Configure here. |
||
| const [isEditingTeamName, setIsEditingTeamName] = useState(false); | ||
| const form = useForm<{ name: string }>({ | ||
| defaultValues: { name: team?.name }, | ||
|
|
||


There was a problem hiding this comment.
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/webhooksnow userequireAdminForMutations, but/api/v2still authenticates with access keys only. Viewers receive anaccessKeyfrom/meand can mutate those resources through v2 while the UI returns 403 on the internal routes.Reviewed by Cursor Bugbot for commit a4a1c5f. Configure here.