refactor(access): fold allowed_users into allowed_principals

Move the model-route access modal off the deprecated allowed_users
policy/field onto the unified allowed_principals + principals surface,
persisting everything through a single /access POST.

- "specific users" radio now uses allowed_principals; a returned legacy
  allowed_users value is normalized so existing routes still select it.
- derive the picker's selection and the full grant set from `principals`
  in GET /access (fall back to legacy `items` if a backend doesn't
  return principals yet).
- save as `principals`: the principal-based override sends its staged
  set; the user picker maps its selection to USER-kind grants and
  preserves any non-user grants from the snapshot (no longer sends
  `users`).
- guard saving before the GET seeds principals (would wipe grants);
  share the ALLOWED_PRINCIPALS_POLICY constant.
- AccessControlFormData: `users` optional, add `principals`.
This commit is contained in:
gitlawr
2026-05-29 17:11:12 +08:00
committed by jialin
parent f71bf1b074
commit a80b889760
4 changed files with 132 additions and 37 deletions
+12 -5
View File
@@ -445,11 +445,18 @@ export async function queryModelAccessUserList(id: number) {
// The response carries `access_policy` alongside `items` so the // The response carries `access_policy` alongside `items` so the
// Access Settings dialog can refresh both halves from a single // Access Settings dialog can refresh both halves from a single
// GET (the calling list snapshot may be stale after a prior // GET (the calling list snapshot may be stale after a prior
// save). // save). `principals` is the full grant set (any kind) used by the
return request<{ items: UserListItem[]; access_policy?: string }>( // principal-based override; `items` stays the USER-only subset.
`${MODEL_ROUTES}/${id}/access`, return request<{
{ method: 'GET' } items: UserListItem[];
); access_policy?: string;
principals?: {
principal_type: string;
principal_id: number;
principal_name?: string;
principal_display_name?: string;
}[];
}>(`${MODEL_ROUTES}/${id}/access`, { method: 'GET' });
} }
export async function updateModelAccessUser(params: { export async function updateModelAccessUser(params: {
@@ -34,6 +34,16 @@ import { AccessControlFormData } from '../../config/types';
type TransferKey = string | number | bigint; type TransferKey = string | number | bigint;
// The "specific users" policy is now ALLOWED_PRINCIPALS with a
// user-only grant list — the same value the principal-based override
// (when a plugin provides one) uses, so the two interoperate.
// `allowed_users` is the deprecated value released in v2.1.x; normalize
// it so legacy routes still select the "specific users" radio (they
// converge to ALLOWED_PRINCIPALS on save).
export const ALLOWED_PRINCIPALS_POLICY = 'allowed_principals';
const normalizeAccessPolicy = (p?: string) =>
p === 'allowed_users' ? ALLOWED_PRINCIPALS_POLICY : p;
const buildAccessScopeTips = ( const buildAccessScopeTips = (
override?: AllowedUsersOverride, override?: AllowedUsersOverride,
prepended: PrependedPolicy[] = [] prepended: PrependedPolicy[] = []
@@ -216,7 +226,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
const handleOnPolicyChange = async (e: RadioChangeEvent) => { const handleOnPolicyChange = async (e: RadioChangeEvent) => {
console.log('policy changed:', e.target.value); console.log('policy changed:', e.target.value);
const policy = e.target.value; const policy = e.target.value;
if (policy === 'allowed_users') { if (policy === ALLOWED_PRINCIPALS_POLICY) {
form.setFieldsValue({ users: formDataCacheRef.current?.users || [] }); form.setFieldsValue({ users: formDataCacheRef.current?.users || [] });
} else { } else {
formDataCacheRef.current = { formDataCacheRef.current = {
@@ -256,16 +266,31 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
// server's authoritative value, which is what survives a save // server's authoritative value, which is what survives a save
// when the parent list hasn't been refreshed. // when the parent list hasn't been refreshed.
form.setFieldsValue({ form.setFieldsValue({
access_policy: currentData?.access_policy access_policy: normalizeAccessPolicy(currentData?.access_policy)
}); });
queryModelAccessUserList(currentData.id).then((res) => { queryModelAccessUserList(currentData.id).then((res) => {
const keys = res.items.map((item) => item.id); // Fall back to the legacy `items` (USER-only) field when an
setTargetKeys(keys); // older backend doesn't return `principals` yet.
const principals =
res.principals ??
res.items?.map((item) => ({
principal_type: 'user',
principal_id: item.id
})) ??
[];
// Derive the user picker's selection from the unified
// `principals` set (USER-kind subset), not the deprecated
// `items` field. `principals` is also kept whole so a save can
// preserve any non-user grants it doesn't manage.
const userKeys = principals
.filter((p) => p.principal_type === 'user')
.map((p) => p.principal_id);
setTargetKeys(userKeys);
let hasAdmin = false; let hasAdmin = false;
let hasInactive = false; let hasInactive = false;
for (const key of keys) { for (const key of userKeys) {
const user = userMap.get(key); const user = userMap.get(key);
if (!user) continue; if (!user) continue;
if (user.is_admin) hasAdmin = true; if (user.is_admin) hasAdmin = true;
@@ -280,8 +305,14 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
setFilterInUsers(filterSet); setFilterInUsers(filterSet);
form.setFieldsValue({ form.setFieldsValue({
access_policy: res.access_policy ?? currentData.access_policy, access_policy: normalizeAccessPolicy(
users: res.items.map((item) => ({ id: item.id })) res.access_policy ?? currentData.access_policy
),
users: userKeys.map((id) => ({ id })),
// Keep the full grant set: read by the principal-based
// override Field, and used on save to preserve non-user
// grants when the user picker submits `principals`.
principals
}); });
}); });
} else { } else {
@@ -340,7 +371,11 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
return ( return (
<Form <Form
form={form} form={form}
onFinish={onFinish} // Submit the full field store (getFieldsValue(true)), not just the
// registered fields onFinish would pass: the principal-based
// override Field manages `principals` via setFieldsValue without a
// registered Form.Item, so it would otherwise be dropped on save.
onFinish={() => onFinish(form.getFieldsValue(true))}
preserve={true} preserve={true}
clearOnDestroy={true} clearOnDestroy={true}
scrollToFirstError={true} scrollToFirstError={true}
@@ -396,7 +431,7 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
label: intl.formatMessage({ label: intl.formatMessage({
id: 'models.accessSettings.allowedUsers' id: 'models.accessSettings.allowedUsers'
}), }),
value: 'allowed_users' value: ALLOWED_PRINCIPALS_POLICY
}, },
{ {
label: intl.formatMessage({ label: intl.formatMessage({
@@ -424,15 +459,14 @@ const AccessControlForm = forwardRef((props: AccessControlFormProps, ref) => {
action={action} action={action}
/> />
)} )}
{allowedUsersOverride && {allowedUsersOverride && accessPolicy === overridePolicyValue && (
accessPolicy === overridePolicyValue && ( <allowedUsersOverride.Field
<allowedUsersOverride.Field form={form}
form={form} routeId={currentData?.id}
routeId={currentData?.id} action={action}
action={action} />
/> )}
)} {!allowedUsersOverride && accessPolicy === ALLOWED_PRINCIPALS_POLICY && (
{!allowedUsersOverride && accessPolicy === 'allowed_users' && (
<> <>
<Label> <Label>
{intl.formatMessage({ id: 'models.table.userSelection' })} {intl.formatMessage({ id: 'models.table.userSelection' })}
@@ -1,5 +1,6 @@
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import { RouteItem } from '@/pages/model-routes/config/types'; import { RouteItem } from '@/pages/model-routes/config/types';
import { getGPUStackPlugin } from '@/plugins';
import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui'; import { AlertBlockInfo, FormDrawer, ModalFooter } from '@gpustack/core-ui';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { message } from 'antd'; import { message } from 'antd';
@@ -7,7 +8,7 @@ import _ from 'lodash';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { updateModelAccessUser } from '../../apis'; import { updateModelAccessUser } from '../../apis';
import { AccessControlFormData } from '../../config/types'; import { AccessControlFormData } from '../../config/types';
import AccessControlForm from './form'; import AccessControlForm, { ALLOWED_PRINCIPALS_POLICY } from './form';
const AccessControlModal: React.FC< const AccessControlModal: React.FC<
Global.ScrollerModalProps<RouteItem, AccessControlFormData> Global.ScrollerModalProps<RouteItem, AccessControlFormData>
@@ -23,17 +24,55 @@ const AccessControlModal: React.FC<
const handleOnFinish = async (values: AccessControlFormData) => { const handleOnFinish = async (values: AccessControlFormData) => {
try { try {
const data: AccessControlFormData = { // Everything saves through the single `/access` POST as
access_policy: values.access_policy, // `principals` (one request, one success toast):
// `users` is only meaningful for the legacy `allowed_users` // * principal-based override active → its staged full grant set.
// policy; for the plugin override (typically the principal- // * user picker → selected users mapped to USER-kind grants,
// based policy) the plugin's Field manages its own state // plus any non-user grants preserved from the GET snapshot
// inline via the principal CRUD endpoints, so we send an // (so a user-only UI never wipes org/group grants it can't
// empty list to clear any stale user grants from a prior // see — though in practice the picker only runs where none
// policy switch. // exist).
users: // * otherwise (authed/public) → send neither, so the server
values.access_policy === 'allowed_users' ? values.users || [] : [] // leaves existing grants untouched.
}; const override = getGPUStackPlugin()?.accessControl?.allowedUsersOverride;
const usesPrincipals =
!!override && values.access_policy === override.policyValue;
const usesUserList =
!override && values.access_policy === ALLOWED_PRINCIPALS_POLICY;
// Guard the load race: on an existing route, `principals` is
// undefined until the GET seeds it. Saving in that window would
// submit an empty grant set and wipe existing grants — bail out
// (the modal stays open; the user can retry once loaded).
if (
currentData?.id &&
(usesPrincipals || usesUserList) &&
!values.principals
) {
return;
}
let data: AccessControlFormData;
if (usesPrincipals) {
data = {
access_policy: values.access_policy,
principals: values.principals || []
};
} else if (usesUserList) {
const preserved = (values.principals || []).filter(
(p) => p.principal_type !== 'user'
);
const userGrants = (values.users || []).map((u) => ({
principal_type: 'user',
principal_id: u.id
}));
data = {
access_policy: values.access_policy,
principals: [...preserved, ...userGrants]
};
} else {
data = { access_policy: values.access_policy };
}
await updateModelAccessUser({ await updateModelAccessUser({
id: currentData?.id as number, id: currentData?.id as number,
data: data data: data
+17 -2
View File
@@ -379,9 +379,24 @@ export interface BackendOption {
export interface AccessControlFormData { export interface AccessControlFormData {
// See `RouteItem.access_policy` for why plugin-defined values are // See `RouteItem.access_policy` for why plugin-defined values are
// accepted alongside the built-ins. // accepted alongside the built-ins. The OSS "specific users" entry
// now writes `allowed_principals` (with a user-only grant list);
// `allowed_users` remains accepted as the deprecated released value.
access_policy: 'public' | 'authed' | 'allowed_users' | (string & {}); access_policy: 'public' | 'authed' | 'allowed_users' | (string & {});
users: { id: number }[]; // Omitted when the caller isn't managing the user list (the
// principal-based override, or authed/public) so the server leaves
// existing grants untouched; an explicit (possibly empty) list
// replaces the route's USER-kind grants.
users?: { id: number }[];
// Full grant set (any kind) submitted by the principal-based override
// on save — replaces the route's entire grant set. OSS leaves it unset
// (it manages users via `users`).
principals?: {
principal_type: string;
principal_id: number;
principal_name?: string;
principal_display_name?: string;
}[];
} }
export interface BackendItem { export interface BackendItem {