mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Harden auth scopes, SQL/WebCRT gates, and per-install JWT secrets.
Add capability RBAC, Alembic bootstrap, optional worker schedulers, and close public docs by default so lab installs stay usable without shared signing keys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
cb8e0d23c5
commit
633a9d55bd
38 changed files with 1808 additions and 452 deletions
|
|
@ -20,6 +20,7 @@ export type AuthUser = {
|
|||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
scopes?: string[];
|
||||
is_active: boolean;
|
||||
must_change_password?: boolean;
|
||||
created_by?: string;
|
||||
|
|
@ -31,10 +32,12 @@ type AuthState = {
|
|||
ready: boolean;
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
scopes: string[];
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshMe: () => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
hasScope: (scope: string) => boolean;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
|
@ -43,22 +46,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const [ready, setReady] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(() => getAuthToken());
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [scopes, setScopes] = useState<string[]>([]);
|
||||
|
||||
const refreshMe = useCallback(async () => {
|
||||
const tok = getAuthToken();
|
||||
if (!tok) {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiGet<{ user: AuthUser }>("/v1/auth/me");
|
||||
const data = await apiGet<{ user: AuthUser; scopes?: string[] }>("/v1/auth/me");
|
||||
setToken(tok);
|
||||
setUser(data.user);
|
||||
setScopes(data.scopes || data.user.scopes || []);
|
||||
} catch {
|
||||
clearAuthToken();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -78,6 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
if (ev.key === null || ev.newValue == null || ev.newValue === "") {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
return;
|
||||
}
|
||||
void refreshMe();
|
||||
|
|
@ -94,6 +102,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setAuthToken(data.access_token);
|
||||
setToken(data.access_token);
|
||||
setUser(data.user);
|
||||
setScopes(data.user.scopes || []);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
|
|
@ -107,6 +116,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
clearAuthToken();
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setScopes([]);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
|
|
@ -114,12 +124,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
ready,
|
||||
token,
|
||||
user,
|
||||
scopes,
|
||||
login,
|
||||
logout,
|
||||
refreshMe,
|
||||
isAdmin: user?.role === "admin",
|
||||
hasScope: (scope: string) => scopes.includes(scope) || user?.role === "admin",
|
||||
}),
|
||||
[ready, token, user, login, logout, refreshMe],
|
||||
[ready, token, user, scopes, login, logout, refreshMe],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export type ModuleDefinition = {
|
|||
descKey?: string;
|
||||
iconTone: ModuleIconTone;
|
||||
titleKey: string;
|
||||
/** Required capability scope to show in workbench (admin bypasses). */
|
||||
requiredScope?: string;
|
||||
adminOnly?: boolean;
|
||||
/** Hide from workbench launcher; still used for module window registration. */
|
||||
workbenchHidden?: boolean;
|
||||
|
|
@ -63,6 +65,7 @@ export const MODULES: readonly ModuleDefinition[] = [
|
|||
descKey: "workbench.cards.webcrtDesc",
|
||||
iconTone: "slate",
|
||||
titleKey: "layout.titleWebcrt",
|
||||
requiredScope: "webcrt:session",
|
||||
},
|
||||
{
|
||||
moduleId: "port-traffic-wall",
|
||||
|
|
|
|||
|
|
@ -817,7 +817,7 @@ export function WebcrtPage() {
|
|||
const pending: TermTab = {
|
||||
key,
|
||||
sessionId,
|
||||
wsUrl: webcrtWsUrl(sessionId),
|
||||
wsUrl: "",
|
||||
termEpoch: (existing?.termEpoch || 0) + 1,
|
||||
target,
|
||||
status: "connecting",
|
||||
|
|
@ -833,9 +833,12 @@ export function WebcrtPage() {
|
|||
return [...without, pending];
|
||||
});
|
||||
setActiveTabKey(key);
|
||||
void webcrtWsUrl(sessionId).then((wsUrl) => {
|
||||
updateTab(key, { wsUrl });
|
||||
});
|
||||
showOk(t("webcrt.opened", { name: deviceLabel(target) }));
|
||||
},
|
||||
[showOk, t],
|
||||
[showOk, t, updateTab],
|
||||
);
|
||||
|
||||
const openAuthForTarget = useCallback((target: CliTargetItem, errorHint?: string) => {
|
||||
|
|
@ -927,7 +930,7 @@ export function WebcrtPage() {
|
|||
async_connect: true,
|
||||
};
|
||||
const sess = await createWebcrtSession(body);
|
||||
const wsUrl = webcrtWsUrl(sess.session_id);
|
||||
const wsUrl = await webcrtWsUrl(sess.session_id);
|
||||
updateTab(key, {
|
||||
sessionId: sess.session_id,
|
||||
wsUrl,
|
||||
|
|
@ -966,9 +969,12 @@ export function WebcrtPage() {
|
|||
status: "connecting",
|
||||
connectPhase: "authenticating",
|
||||
termEpoch: tab.termEpoch + 1,
|
||||
wsUrl: webcrtWsUrl(tab.sessionId),
|
||||
wsUrl: "",
|
||||
errorMessage: undefined,
|
||||
});
|
||||
void webcrtWsUrl(tab.sessionId).then((wsUrl) => {
|
||||
updateTab(tab.key, { wsUrl });
|
||||
});
|
||||
setActiveTabKey(tab.key);
|
||||
return true;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const SECTIONS: WorkbenchSection[] = ["monitoring", "operations", "system"];
|
|||
|
||||
export function WorkbenchPage() {
|
||||
const { t } = useI18n();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, hasScope } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="workbench">
|
||||
|
|
@ -19,6 +19,7 @@ export function WorkbenchPage() {
|
|||
{modulesInSection(section)
|
||||
.filter((mod) => !mod.workbenchHidden)
|
||||
.filter((mod) => !mod.adminOnly || isAdmin)
|
||||
.filter((mod) => !mod.requiredScope || hasScope(mod.requiredScope) || isAdmin)
|
||||
.map((mod) => (
|
||||
<button
|
||||
key={mod.moduleId}
|
||||
|
|
|
|||
|
|
@ -817,11 +817,18 @@ export function webcrtSftpUpload(
|
|||
return withSftpRetries(() => webcrtSftpUploadOnce(body, opts), opts);
|
||||
}
|
||||
|
||||
export const webcrtWsUrl = (sessionId: string): string => {
|
||||
export async function webcrtWsUrl(sessionId: string): Promise<string> {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const path = `/v1/webcrt/sessions/${encodeURIComponent(sessionId)}/ws`;
|
||||
const tok = getAuthToken();
|
||||
const qs = tok ? `?access_token=${encodeURIComponent(tok)}` : "";
|
||||
let qs = "";
|
||||
try {
|
||||
const ticketResp = await apiPost<{ ticket: string; expires_in: number }>("/v1/webcrt/ws-ticket", {});
|
||||
if (ticketResp?.ticket) {
|
||||
qs = `?ws_ticket=${encodeURIComponent(ticketResp.ticket)}`;
|
||||
}
|
||||
} catch {
|
||||
qs = "";
|
||||
}
|
||||
// Optional override, e.g. ws://127.0.0.1:8890
|
||||
const override = String((import.meta as ImportMeta & { env?: Record<string, string> }).env?.VITE_NETX_WS_BASE || "").trim();
|
||||
if (override) {
|
||||
|
|
@ -834,7 +841,7 @@ export const webcrtWsUrl = (sessionId: string): string => {
|
|||
return `${proto}//${apiHost}:8890${path}${qs}`;
|
||||
}
|
||||
return `${proto}//${window.location.host}${path}${qs}`;
|
||||
};
|
||||
}
|
||||
|
||||
export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue