Add custom access type for bearer keys to support combined group and server scoping (#530)

Co-authored-by: samanhappy <samanhappy@gmail.com>
This commit is contained in:
Copilot
2025-12-27 16:16:50 +08:00
committed by GitHub
parent b00e1c81fc
commit ab338e80a7
10 changed files with 216 additions and 76 deletions

View File

@@ -25,7 +25,7 @@ interface BearerKeyRowProps {
name: string; name: string;
token: string; token: string;
enabled: boolean; enabled: boolean;
accessType: 'all' | 'groups' | 'servers'; accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string; allowedGroups: string;
allowedServers: string; allowedServers: string;
}, },
@@ -47,7 +47,7 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
const [name, setName] = useState(keyData.name); const [name, setName] = useState(keyData.name);
const [token, setToken] = useState(keyData.token); const [token, setToken] = useState(keyData.token);
const [enabled, setEnabled] = useState<boolean>(keyData.enabled); const [enabled, setEnabled] = useState<boolean>(keyData.enabled);
const [accessType, setAccessType] = useState<'all' | 'groups' | 'servers'>( const [accessType, setAccessType] = useState<'all' | 'groups' | 'servers' | 'custom'>(
keyData.accessType || 'all', keyData.accessType || 'all',
); );
const [selectedGroups, setSelectedGroups] = useState<string[]>(keyData.allowedGroups || []); const [selectedGroups, setSelectedGroups] = useState<string[]>(keyData.allowedGroups || []);
@@ -105,6 +105,13 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
); );
return; return;
} }
if (accessType === 'custom' && selectedGroups.length === 0 && selectedServers.length === 0) {
showToast(
t('settings.selectAtLeastOneGroupOrServer') || 'Please select at least one group or server',
'error',
);
return;
}
setSaving(true); setSaving(true);
try { try {
@@ -135,6 +142,31 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
}; };
const isGroupsMode = accessType === 'groups'; const isGroupsMode = accessType === 'groups';
const isCustomMode = accessType === 'custom';
// Helper function to format access type display text
const formatAccessTypeDisplay = (key: BearerKey): string => {
if (key.accessType === 'all') {
return t('settings.bearerKeyAccessAll') || 'All Resources';
}
if (key.accessType === 'groups') {
return `${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${key.allowedGroups}`;
}
if (key.accessType === 'servers') {
return `${t('settings.bearerKeyAccessServers') || 'Servers'}: ${key.allowedServers}`;
}
if (key.accessType === 'custom') {
const parts: string[] = [];
if (key.allowedGroups && key.allowedGroups.length > 0) {
parts.push(`${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${key.allowedGroups}`);
}
if (key.allowedServers && key.allowedServers.length > 0) {
parts.push(`${t('settings.bearerKeyAccessServers') || 'Servers'}: ${key.allowedServers}`);
}
return `${t('settings.bearerKeyAccessCustom') || 'Custom'}: ${parts.join('; ')}`;
}
return '';
};
if (isEditing) { if (isEditing) {
return ( return (
@@ -194,7 +226,9 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
<select <select
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-select transition-shadow duration-200" className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-select transition-shadow duration-200"
value={accessType} value={accessType}
onChange={(e) => setAccessType(e.target.value as 'all' | 'groups' | 'servers')} onChange={(e) =>
setAccessType(e.target.value as 'all' | 'groups' | 'servers' | 'custom')
}
disabled={loading} disabled={loading}
> >
<option value="all">{t('settings.bearerKeyAccessAll') || 'All Resources'}</option> <option value="all">{t('settings.bearerKeyAccessAll') || 'All Resources'}</option>
@@ -204,9 +238,14 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
<option value="servers"> <option value="servers">
{t('settings.bearerKeyAccessServers') || 'Specific Servers'} {t('settings.bearerKeyAccessServers') || 'Specific Servers'}
</option> </option>
<option value="custom">
{t('settings.bearerKeyAccessCustom') || 'Custom (Groups & Servers)'}
</option>
</select> </select>
</div> </div>
{/* Show single selector for groups or servers mode */}
{!isCustomMode && (
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
<label <label
className={`block text-sm font-medium mb-1 ${accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`} className={`block text-sm font-medium mb-1 ${accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
@@ -227,6 +266,37 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
disabled={loading || accessType === 'all'} disabled={loading || accessType === 'all'}
/> />
</div> </div>
)}
{/* Show both selectors for custom mode */}
{isCustomMode && (
<>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedGroups') || 'Allowed groups'}
</label>
<MultiSelect
options={availableGroups}
selected={selectedGroups}
onChange={setSelectedGroups}
placeholder={t('settings.selectGroups') || 'Select groups...'}
disabled={loading}
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={availableServers}
selected={selectedServers}
onChange={setSelectedServers}
placeholder={t('settings.selectServers') || 'Select servers...'}
disabled={loading}
/>
</div>
</>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
@@ -281,11 +351,7 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{keyData.accessType === 'all' {formatAccessTypeDisplay(keyData)}
? t('settings.bearerKeyAccessAll') || 'All Resources'
: keyData.accessType === 'groups'
? `${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${keyData.allowedGroups}`
: `${t('settings.bearerKeyAccessServers') || 'Servers'}: ${keyData.allowedServers}`}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button <button
@@ -737,7 +803,7 @@ const SettingsPage: React.FC = () => {
name: string; name: string;
token: string; token: string;
enabled: boolean; enabled: boolean;
accessType: 'all' | 'groups' | 'servers'; accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string; allowedGroups: string;
allowedServers: string; allowedServers: string;
}>({ }>({
@@ -765,10 +831,10 @@ const SettingsPage: React.FC = () => {
// Reset selected arrays when accessType changes // Reset selected arrays when accessType changes
useEffect(() => { useEffect(() => {
if (newBearerKey.accessType !== 'groups') { if (newBearerKey.accessType !== 'groups' && newBearerKey.accessType !== 'custom') {
setNewSelectedGroups([]); setNewSelectedGroups([]);
} }
if (newBearerKey.accessType !== 'servers') { if (newBearerKey.accessType !== 'servers' && newBearerKey.accessType !== 'custom') {
setNewSelectedServers([]); setNewSelectedServers([]);
} }
}, [newBearerKey.accessType]); }, [newBearerKey.accessType]);
@@ -866,6 +932,17 @@ const SettingsPage: React.FC = () => {
); );
return; return;
} }
if (
newBearerKey.accessType === 'custom' &&
newSelectedGroups.length === 0 &&
newSelectedServers.length === 0
) {
showToast(
t('settings.selectAtLeastOneGroupOrServer') || 'Please select at least one group or server',
'error',
);
return;
}
await createBearerKey({ await createBearerKey({
name: newBearerKey.name, name: newBearerKey.name,
@@ -873,11 +950,13 @@ const SettingsPage: React.FC = () => {
enabled: newBearerKey.enabled, enabled: newBearerKey.enabled,
accessType: newBearerKey.accessType, accessType: newBearerKey.accessType,
allowedGroups: allowedGroups:
newBearerKey.accessType === 'groups' && newSelectedGroups.length > 0 (newBearerKey.accessType === 'groups' || newBearerKey.accessType === 'custom') &&
newSelectedGroups.length > 0
? newSelectedGroups ? newSelectedGroups
: undefined, : undefined,
allowedServers: allowedServers:
newBearerKey.accessType === 'servers' && newSelectedServers.length > 0 (newBearerKey.accessType === 'servers' || newBearerKey.accessType === 'custom') &&
newSelectedServers.length > 0
? newSelectedServers ? newSelectedServers
: undefined, : undefined,
} as any); } as any);
@@ -901,7 +980,7 @@ const SettingsPage: React.FC = () => {
name: string; name: string;
token: string; token: string;
enabled: boolean; enabled: boolean;
accessType: 'all' | 'groups' | 'servers'; accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string; allowedGroups: string;
allowedServers: string; allowedServers: string;
}, },
@@ -1128,7 +1207,7 @@ const SettingsPage: React.FC = () => {
onChange={(e) => onChange={(e) =>
setNewBearerKey((prev) => ({ setNewBearerKey((prev) => ({
...prev, ...prev,
accessType: e.target.value as 'all' | 'groups' | 'servers', accessType: e.target.value as 'all' | 'groups' | 'servers' | 'custom',
})) }))
} }
disabled={loading} disabled={loading}
@@ -1142,9 +1221,13 @@ const SettingsPage: React.FC = () => {
<option value="servers"> <option value="servers">
{t('settings.bearerKeyAccessServers') || 'Specific Servers'} {t('settings.bearerKeyAccessServers') || 'Specific Servers'}
</option> </option>
<option value="custom">
{t('settings.bearerKeyAccessCustom') || 'Custom (Groups & Servers)'}
</option>
</select> </select>
</div> </div>
{newBearerKey.accessType !== 'custom' && (
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
<label <label
className={`block text-sm font-medium mb-1 ${newBearerKey.accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`} className={`block text-sm font-medium mb-1 ${newBearerKey.accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
@@ -1177,6 +1260,36 @@ const SettingsPage: React.FC = () => {
disabled={loading || newBearerKey.accessType === 'all'} disabled={loading || newBearerKey.accessType === 'all'}
/> />
</div> </div>
)}
{newBearerKey.accessType === 'custom' && (
<>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedGroups') || 'Allowed groups'}
</label>
<MultiSelect
options={availableGroups}
selected={newSelectedGroups}
onChange={setNewSelectedGroups}
placeholder={t('settings.selectGroups') || 'Select groups...'}
disabled={loading}
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={availableServers}
selected={newSelectedServers}
onChange={setNewSelectedServers}
placeholder={t('settings.selectServers') || 'Select servers...'}
disabled={loading}
/>
</div>
</>
)}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button

View File

@@ -310,7 +310,7 @@ export interface ApiResponse<T = any> {
} }
// Bearer authentication key configuration (frontend view model) // Bearer authentication key configuration (frontend view model)
export type BearerKeyAccessType = 'all' | 'groups' | 'servers'; export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey { export interface BearerKey {
id: string; id: string;

View File

@@ -568,6 +568,7 @@
"bearerKeyAccessAll": "All", "bearerKeyAccessAll": "All",
"bearerKeyAccessGroups": "Groups", "bearerKeyAccessGroups": "Groups",
"bearerKeyAccessServers": "Servers", "bearerKeyAccessServers": "Servers",
"bearerKeyAccessCustom": "Custom",
"bearerKeyAllowedGroups": "Allowed groups", "bearerKeyAllowedGroups": "Allowed groups",
"bearerKeyAllowedServers": "Allowed servers", "bearerKeyAllowedServers": "Allowed servers",
"addBearerKey": "Add key", "addBearerKey": "Add key",

View File

@@ -569,6 +569,7 @@
"bearerKeyAccessAll": "Toutes", "bearerKeyAccessAll": "Toutes",
"bearerKeyAccessGroups": "Groupes", "bearerKeyAccessGroups": "Groupes",
"bearerKeyAccessServers": "Serveurs", "bearerKeyAccessServers": "Serveurs",
"bearerKeyAccessCustom": "Personnalisée",
"bearerKeyAllowedGroups": "Groupes autorisés", "bearerKeyAllowedGroups": "Groupes autorisés",
"bearerKeyAllowedServers": "Serveurs autorisés", "bearerKeyAllowedServers": "Serveurs autorisés",
"addBearerKey": "Ajouter une clé", "addBearerKey": "Ajouter une clé",

View File

@@ -569,6 +569,7 @@
"bearerKeyAccessAll": "Tümü", "bearerKeyAccessAll": "Tümü",
"bearerKeyAccessGroups": "Gruplar", "bearerKeyAccessGroups": "Gruplar",
"bearerKeyAccessServers": "Sunucular", "bearerKeyAccessServers": "Sunucular",
"bearerKeyAccessCustom": "Özel",
"bearerKeyAllowedGroups": "İzin verilen gruplar", "bearerKeyAllowedGroups": "İzin verilen gruplar",
"bearerKeyAllowedServers": "İzin verilen sunucular", "bearerKeyAllowedServers": "İzin verilen sunucular",
"addBearerKey": "Anahtar ekle", "addBearerKey": "Anahtar ekle",

View File

@@ -570,6 +570,7 @@
"bearerKeyAccessAll": "全部", "bearerKeyAccessAll": "全部",
"bearerKeyAccessGroups": "指定分组", "bearerKeyAccessGroups": "指定分组",
"bearerKeyAccessServers": "指定服务器", "bearerKeyAccessServers": "指定服务器",
"bearerKeyAccessCustom": "自定义",
"bearerKeyAllowedGroups": "允许访问的分组", "bearerKeyAllowedGroups": "允许访问的分组",
"bearerKeyAllowedServers": "允许访问的服务器", "bearerKeyAllowedServers": "允许访问的服务器",
"addBearerKey": "新增密钥", "addBearerKey": "新增密钥",

View File

@@ -57,7 +57,7 @@ export const createBearerKey = async (req: Request, res: Response): Promise<void
return; return;
} }
if (!accessType || !['all', 'groups', 'servers'].includes(accessType)) { if (!accessType || !['all', 'groups', 'servers', 'custom'].includes(accessType)) {
res.status(400).json({ success: false, message: 'Invalid accessType' }); res.status(400).json({ success: false, message: 'Invalid accessType' });
return; return;
} }
@@ -104,7 +104,7 @@ export const updateBearerKey = async (req: Request, res: Response): Promise<void
if (token !== undefined) updates.token = token; if (token !== undefined) updates.token = token;
if (enabled !== undefined) updates.enabled = enabled; if (enabled !== undefined) updates.enabled = enabled;
if (accessType !== undefined) { if (accessType !== undefined) {
if (!['all', 'groups', 'servers'].includes(accessType)) { if (!['all', 'groups', 'servers', 'custom'].includes(accessType)) {
res.status(400).json({ success: false, message: 'Invalid accessType' }); res.status(400).json({ success: false, message: 'Invalid accessType' });
return; return;
} }

View File

@@ -25,7 +25,7 @@ export class BearerKey {
enabled: boolean; enabled: boolean;
@Column({ type: 'varchar', length: 20, default: 'all' }) @Column({ type: 'varchar', length: 20, default: 'all' })
accessType: 'all' | 'groups' | 'servers'; accessType: 'all' | 'groups' | 'servers' | 'custom';
@Column({ type: 'simple-json', nullable: true }) @Column({ type: 'simple-json', nullable: true })
allowedGroups?: string[]; allowedGroups?: string[];

View File

@@ -88,6 +88,29 @@ const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promi
return groupServerNames.some((name) => allowedServers.includes(name)); return groupServerNames.some((name) => allowedServers.includes(name));
} }
if (key.accessType === 'custom') {
// For custom-scoped keys, check if the group is allowed OR if any server in the group is allowed
const allowedGroups = key.allowedGroups || [];
const allowedServers = key.allowedServers || [];
// Check if the group itself is allowed
const groupAllowed =
allowedGroups.includes(matchedGroup.name) || allowedGroups.includes(matchedGroup.id);
if (groupAllowed) {
return true;
}
// Check if any server in the group is allowed
if (allowedServers.length > 0 && Array.isArray(matchedGroup.servers)) {
const groupServerNames = matchedGroup.servers.map((server) =>
typeof server === 'string' ? server : server.name,
);
return groupServerNames.some((name) => allowedServers.includes(name));
}
return false;
}
// Unknown accessType with matched group // Unknown accessType with matched group
return false; return false;
} }
@@ -102,8 +125,8 @@ const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promi
return false; return false;
} }
if (key.accessType === 'servers') { if (key.accessType === 'servers' || key.accessType === 'custom') {
// For server-scoped keys, check if the server is in allowedServers // For server-scoped or custom-scoped keys, check if the server is in allowedServers
const allowedServers = key.allowedServers || []; const allowedServers = key.allowedServers || [];
return allowedServers.includes(matchedServer.name); return allowedServers.includes(matchedServer.name);
} }

View File

@@ -244,7 +244,7 @@ export interface OAuthServerConfig {
} }
// Bearer authentication key configuration // Bearer authentication key configuration
export type BearerKeyAccessType = 'all' | 'groups' | 'servers'; export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey { export interface BearerKey {
id: string; // Unique identifier for the key id: string; // Unique identifier for the key
@@ -252,8 +252,8 @@ export interface BearerKey {
token: string; // Bearer token value token: string; // Bearer token value
enabled: boolean; // Whether this key is enabled enabled: boolean; // Whether this key is enabled
accessType: BearerKeyAccessType; // Access scope type accessType: BearerKeyAccessType; // Access scope type
allowedGroups?: string[]; // Allowed group names when accessType === 'groups' allowedGroups?: string[]; // Allowed group names when accessType === 'groups' or 'custom'
allowedServers?: string[]; // Allowed server names when accessType === 'servers' allowedServers?: string[]; // Allowed server names when accessType === 'servers' or 'custom'
} }
// Represents the settings for MCP servers // Represents the settings for MCP servers