Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2908be6
feat(permissions): align project roles with UI access
brynne8 Jul 28, 2026
1f2cc17
fix(permissions): preserve legacy invitation access
brynne8 Jul 29, 2026
bed9227
fix(permissions): preserve legacy invitation scope
brynne8 Jul 29, 2026
5e06dfe
test(migration): cover viewer prompt permissions
brynne8 Jul 29, 2026
f4e7861
fix(invitations): grant legacy invitees read access
brynne8 Jul 29, 2026
41410b1
fix(invitations): backfill legacy invitation roles
brynne8 Jul 29, 2026
11c31b2
fix(invitations): default legacy invites to developer
brynne8 Jul 29, 2026
a2ae73f
docs(invitations): document exported permission types
brynne8 Jul 29, 2026
52810ec
fix(migration): skip revoked legacy invitations
brynne8 Jul 29, 2026
4bd1b0c
fix: add default pagination for useRoles hook
brynne8 Jul 29, 2026
daa5e1c
fix: restore soft-deleted Developer role in beta7 migration
brynne8 Jul 29, 2026
6a289db
fix: extract numeric ID from global ID for invitation role
brynne8 Jul 29, 2026
3e0faa6
fix: prevent deleting Developer role and clear stale UserRole on restore
brynne8 Jul 29, 2026
80d7502
refactor: remove soft-delete restoration for Developer role
brynne8 Jul 29, 2026
dfe8995
fix: clean up soft-deleted Developer roles and recreate with default …
brynne8 Jul 29, 2026
d3a09ac
fix(permissions): enforce project role access
brynne8 Jul 30, 2026
d8b55c8
fix(migration): preserve unrelated deleted roles
brynne8 Jul 30, 2026
01bdaad
fix(api-keys): restrict project key creation to admins
brynne8 Jul 30, 2026
b46b3eb
fix(permissions): align Developer role defaults and model access
brynne8 Jul 30, 2026
cb285ed
fix(migration): preserve roles during invitation backfill
brynne8 Jul 30, 2026
9531ceb
Revert "fix(migration): preserve roles during invitation backfill"
brynne8 Jul 30, 2026
d63b937
fix(migration): skip invalid legacy invitations
brynne8 Jul 30, 2026
481b774
chore: resolve conflicts with unstable
brynne8 Jul 30, 2026
11bc1a0
fix(apikeys): avoid unauthorized project template field
brynne8 Jul 30, 2026
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
46 changes: 46 additions & 0 deletions PERMISSION_UI_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Permission And UI Plan

## Goals

- Project roles grant usable project access without requiring system scopes.
- The server publishes effective project scopes so API authorization, routes, menus, and controls share one source of truth.
- Read permission shows information; write permission alone exposes mutation controls.
- Project members select from project-approved, enabled models without viewing global Channel configuration.

## Role Presets

New projects create these project roles. Invitations bind to a concrete project role when created.

| Role | Scope set | Intended use |
| --- | --- | --- |
| Project Admin | `read/write_users`, `read/write_roles`, `read/write_api_keys`, `read/write_prompts`, `read/write_requests` | Manage the project, its members, and its resources. |
| Developer | `read/write_api_keys`, `read/write_prompts`, `write_requests` | Use the project, manage API keys and prompts, and use Playground. Personal request visibility is not treated as project-wide request access. |
| Viewer | `read_prompts`, `read_requests` | Inspect permitted project data without changing it. |

System Channels, Models, Data Storage, Settings, and global analytics remain system-level resources. A project role never receives `read_channels` or `write_channels` merely to use Playground.

## Authorization Contract

1. `UserProjectInfo.effectiveScopes` is the union of direct membership scopes and roles assigned in that project.
2. Frontend navigation, route guards, and mutation affordances use `effectiveScopes` for the selected project.
3. The server continues to authorize every query and mutation. A hidden control is never an authorization boundary.
4. Invitation creation validates that the selected role belongs to the invitation project and that the inviter may grant its scopes. Registration creates membership and role assignment in one transaction.

## UI Contract

| Capability | UI behavior |
| --- | --- |
| Read resource | Show route, list, detail values, and static state. |
| Write resource | Show create, edit, delete, bulk actions, switches, inline editors, and ordering controls. |
| Read-only Channel/Model/Prompt | Render status as a non-interactive badge. Hide mutation columns and controls. |
| Project Playground | Query project-profile-approved enabled models only. Disabled or excluded Channels are never listed. |

The system Channel list remains an audit/configuration view: a system user with `read_channels` can see disabled Channels as static rows. The project model picker is a separate availability view and excludes them at the API boundary.

## Delivery Order

1. Add effective scopes and correct default project roles.
2. Bind invitations to a validated project role.
3. Switch frontend permission consumers to effective scopes and align the Playground navigation guard.
4. Gate read-only controls in Channel, Model, Prompt, and API Key surfaces.
5. Add focused tests for role assignment, effective scopes, invitation registration, and read-only UI behavior.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IconPlus, IconTemplate } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { PermissionGuard } from '@/components/permission-guard';
import { useApiKeysContext } from '../context/apikeys-context';

export function ApiKeysPrimaryButtons() {
Expand All @@ -9,14 +10,16 @@ export function ApiKeysPrimaryButtons() {

return (
<div className='flex gap-2'>
<Button variant='outline' size='sm' onClick={() => openDialog('profileTemplates')}>
<IconTemplate className='mr-2 h-4 w-4' />
{t('apikeys.profileTemplates.button')}
</Button>
<Button onClick={() => openDialog('create')} size='sm'>
<IconPlus className='mr-2 h-4 w-4' />
{t('apikeys.createApiKey')}
</Button>
<PermissionGuard requiredScope='write_api_keys'>
<Button variant='outline' size='sm' onClick={() => openDialog('profileTemplates')}>
<IconTemplate className='mr-2 h-4 w-4' />
{t('apikeys.profileTemplates.button')}
</Button>
<Button onClick={() => openDialog('create')} size='sm'>
<IconPlus className='mr-2 h-4 w-4' />
{t('apikeys.createApiKey')}
</Button>
</PermissionGuard>
</div>
);
}
10 changes: 10 additions & 0 deletions frontend/src/features/channels/components/channels-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const clampWeight = (value: number) => formatWeight(Math.min(MAX_WEIGHT, Math.ma
const StatusSwitchCell = memo(({ row }: { row: Row<Channel> }) => {
const channel = row.original;
const [dialogOpen, setDialogOpen] = useState(false);
const { channelPermissions } = usePermissions();

const isEnabled = channel.status === 'enabled';
const isArchived = channel.status === 'archived';
Expand All @@ -70,6 +71,10 @@ const StatusSwitchCell = memo(({ row }: { row: Row<Channel> }) => {
}
}, [isArchived]);

if (!channelPermissions.canWrite) {
return <Badge variant='outline'>{channel.status}</Badge>;
}

return (
<div className='flex justify-center'>
<Switch checked={isEnabled} onCheckedChange={handleSwitchClick} disabled={isArchived} data-testid='channel-status-switch' />
Expand Down Expand Up @@ -526,6 +531,7 @@ const OrderingWeightCell = memo(({ row }: { row: Row<Channel> }) => {
const [isEditing, setIsEditing] = useState(false);
const [weight, setWeight] = useState<string>(initialWeight?.toString() || '1');
const updateChannel = useUpdateChannel();
const { channelPermissions } = usePermissions();
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -570,6 +576,10 @@ const OrderingWeightCell = memo(({ row }: { row: Row<Channel> }) => {
[handleSave, initialWeight]
);

if (!channelPermissions.canWrite) {
return <span className={cn('font-mono text-sm', initialWeight == null && 'text-muted-foreground')}>{initialWeight ?? '-'}</span>;
}

if (isEditing) {
return (
<div className='flex justify-center px-2'>
Expand Down
25 changes: 15 additions & 10 deletions frontend/src/features/models/components/models-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { useDeveloperLabel } from './models-table';
function StatusSwitchCell({ row }: { row: Row<Model> }) {
const model = row.original;
const [dialogOpen, setDialogOpen] = useState(false);
const { channelPermissions } = usePermissions();

const isEnabled = model.status === 'enabled';
const isArchived = model.status === 'archived';
Expand All @@ -31,6 +32,10 @@ function StatusSwitchCell({ row }: { row: Row<Model> }) {
}
}, [isArchived]);

if (!channelPermissions.canWrite) {
return <Badge variant='outline'>{model.status}</Badge>;
}

return (
<>
<Switch checked={isEnabled} onCheckedChange={handleSwitchClick} disabled={isArchived} data-testid='model-status-switch' />
Expand Down Expand Up @@ -328,15 +333,15 @@ export const createColumns = (t: ReturnType<typeof useTranslation>['t'], canWrit
enableSorting: true,
enableHiding: false,
},
{
id: 'actions',
header: t('common.columns.actions'),
cell: DataTableRowActions,
meta: {
className: 'w-[88px] min-w-[88px] pr-3 pl-0',
},
enableSorting: false,
enableHiding: false,
},
...(canWrite
? [{
id: 'actions',
header: t('common.columns.actions'),
cell: DataTableRowActions,
meta: { className: 'w-[88px] min-w-[88px] pr-3 pl-0' },
enableSorting: false,
enableHiding: false,
}]
: []),
];
};
43 changes: 25 additions & 18 deletions frontend/src/features/playground/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import { PromptInput, PromptInputTextarea, PromptInputSubmit } from '@/component
import { Reasoning, ReasoningTrigger, ReasoningContent } from '@/components/ai-elements/reasoning';
import { Response as UIResponse } from '@/components/ai-elements/response';
import { AutoCompleteSelect } from '@/components/auto-complete-select';
import { useQueryChannels } from '@/features/channels/data/channels';
import { useAllChannelSummarys } from '@/features/channels/data/channels';
import { useQueryModels } from '@/features/models/data/models';
import { usePermissions } from '@/hooks/usePermissions';
import { cn } from '@/lib/utils';

type PlaygroundModelSource = 'channel' | 'model_gateway';

Expand Down Expand Up @@ -71,15 +73,11 @@ export default function Playground() {

const { accessToken } = useAuthStore((state) => state.auth);
const selectedProjectId = useSelectedProjectId();
const { hasSystemScope } = usePermissions();
const canUseModelGateway = hasSystemScope('read_channels');

// 获取 channels 数据
const { data: channelsData, isLoading: channelsLoading } = useQueryChannels({
first: 100,
orderBy: { field: 'ORDERING_WEIGHT', direction: 'DESC' },
where: {
statusIn: ['enabled', 'disabled'],
},
});
const { data: channelsData, isLoading: channelsLoading } = useAllChannelSummarys(selectedProjectId);
const isModelGatewaySource = modelSource === 'model_gateway';
const { data: modelsData, isLoading: modelsLoading } = useQueryModels(
{
Expand All @@ -90,7 +88,7 @@ export default function Playground() {
typeIn: ['chat'],
},
},
{ enabled: isModelGatewaySource }
{ enabled: isModelGatewaySource && canUseModelGateway }
);

const [input, setInput] = useState('');
Expand Down Expand Up @@ -232,7 +230,7 @@ export default function Playground() {
const channelOptions = useMemo(() => {
if (!channelsData?.edges) return [];
return channelsData.edges
.filter((edge) => edge.node.supportedModels.length > 0)
.filter((edge) => edge.node.allModelEntries.length > 0)
.map((edge) => ({
value: edge.node.id,
label: edge.node.name,
Expand All @@ -256,9 +254,9 @@ export default function Playground() {
if (!channelsData?.edges || !selectedChannel) return [];
const channelEdge = channelsData.edges.find((edge) => edge.node.id === selectedChannel);
if (!channelEdge) return [];
return channelEdge.node.supportedModels.map((m) => ({
value: m,
label: m,
return channelEdge.node.allModelEntries.map((entry) => ({
value: entry.requestModel,
label: entry.requestModel,
}));
}, [channelsData, isModelGatewaySource, modelPageModelOptions, selectedChannel]);

Expand All @@ -269,7 +267,7 @@ export default function Playground() {
(channelId: string) => {
setSelectedChannel(channelId);
const channelEdge = channelsData?.edges?.find((edge) => edge.node.id === channelId);
const firstModel = channelEdge?.node.supportedModels[0] ?? '';
const firstModel = channelEdge?.node.allModelEntries[0]?.requestModel ?? '';
setModel(firstModel);
},
[channelsData]
Expand All @@ -278,17 +276,26 @@ export default function Playground() {
const handleModelSourceChange = useCallback(
(source: string) => {
const nextSource = source as PlaygroundModelSource;
if (nextSource === 'model_gateway' && !canUseModelGateway) {
return;
}
setModelSource(nextSource);
if (nextSource === 'model_gateway') {
setModel(modelPageModelOptions[0]?.value ?? '');
return;
}
const channelEdge = channelsData?.edges?.find((edge) => edge.node.id === selectedChannel);
setModel(channelEdge?.node.supportedModels[0] ?? '');
setModel(channelEdge?.node.allModelEntries[0]?.requestModel ?? '');
},
[channelsData, modelPageModelOptions, selectedChannel]
[canUseModelGateway, channelsData, modelPageModelOptions, selectedChannel]
);

useEffect(() => {
if (!canUseModelGateway && modelSource === 'model_gateway') {
setModelSource('channel');
}
}, [canUseModelGateway, modelSource]);

// 初始化:默认选第一个渠道和第一个模型
useEffect(() => {
if (!selectedChannel && !channelsLoading && channelOptions.length > 0) {
Expand Down Expand Up @@ -333,9 +340,9 @@ export default function Playground() {
<div className='space-y-3'>
<Label className='text-xs font-semibold'>{t('playground.settings.modelSource')}</Label>
<Tabs value={modelSource} onValueChange={handleModelSourceChange}>
<TabsList className='grid w-full grid-cols-2'>
<TabsList className={cn('grid w-full', canUseModelGateway ? 'grid-cols-2' : 'grid-cols-1')}>
<TabsTrigger value='channel'>{t('playground.settings.channel')}</TabsTrigger>
<TabsTrigger value='model_gateway'>{t('playground.settings.modelGateway')}</TabsTrigger>
{canUseModelGateway && <TabsTrigger value='model_gateway'>{t('playground.settings.modelGateway')}</TabsTrigger>}
</TabsList>
</Tabs>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
Expand All @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { apiRequest } from '@/lib/api-client';
import { useSelectedProjectId } from '@/stores/projectStore';
import { useRoles } from '@/features/project-roles/data/roles';
import { Button } from '@/components/ui/button';
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
Expand All @@ -17,6 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
const formSchema = z.object({
expiresInHours: z.enum(['1', '6', '24', '168', '0']),
maxUses: z.enum(['1', '0']),
roleID: z.string().min(1),
});

type InviteForm = z.infer<typeof formSchema>;
Expand All @@ -31,11 +33,21 @@ export function UsersInviteDialog({ open, onOpenChange }: Props) {
const selectedProjectId = useSelectedProjectId();
const [inviteLink, setInviteLink] = useState('');
const [isCopied, setIsCopied] = useState(false);
const { data: rolesData, isLoading: isLoadingRoles } = useRoles();
const form = useForm<InviteForm>({
resolver: zodResolver(formSchema),
defaultValues: { expiresInHours: '168', maxUses: '1' },
defaultValues: { expiresInHours: '168', maxUses: '1', roleID: '' },
});

const roles = rolesData?.edges.map((edge) => edge.node) || [];

useEffect(() => {
if (form.getValues('roleID') || roles.length === 0) {
return;
}
form.setValue('roleID', roles.find((role) => role.name === 'Developer')?.id || roles[0].id);
}, [form, roles]);

const closeDialog = (nextOpen: boolean) => {
if (!nextOpen) {
form.reset();
Expand All @@ -58,6 +70,7 @@ export function UsersInviteDialog({ open, onOpenChange }: Props) {
body: {
expiresInHours: Number(values.expiresInHours),
maxUses: Number(values.maxUses),
roleID: Number(values.roleID),
},
});
const url = new URL('/sign-up', window.location.origin);
Expand Down Expand Up @@ -103,6 +116,28 @@ export function UsersInviteDialog({ open, onOpenChange }: Props) {
) : (
<Form {...form}>
<form id='project-user-invite-form' onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
<FormField
control={form.control}
name='roleID'
render={({ field }) => (
<FormItem>
<FormLabel>{t('users.form.projectRoles')}</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={isLoadingRoles || roles.length === 0}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={isLoadingRoles ? t('users.form.loadingRoles') : t('users.form.noProjectRoles')} />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expiresInHours'
Expand Down Expand Up @@ -159,7 +194,7 @@ export function UsersInviteDialog({ open, onOpenChange }: Props) {
<DialogClose asChild>
<Button variant='outline'>{t('common.buttons.cancel')}</Button>
</DialogClose>
<Button type='submit' form='project-user-invite-form' disabled={form.formState.isSubmitting}>
<Button type='submit' form='project-user-invite-form' disabled={form.formState.isSubmitting || isLoadingRoles || roles.length === 0}>
{t('users.buttons.createInvitation')}
</Button>
</>
Expand Down
Loading
Loading