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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user