diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 3e03ecc5..45fd4d20 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -36,9 +36,12 @@ _OIDC_FIELDS = ("issuer", "client_id", "client_secret", "scopes", "username_clai def principal_out(p: Principal) -> dict[str, Any]: + from app.auth import personal_keys_enabled + + # personal_keys:管理员开没开个人访问密钥(next-round 04 A1),不是管理员的人在设置里据此给不给「访问密钥」一页 return {"mode": p.mode, "username": p.username, "display_name": p.display_name or p.username, "role": p.role or "user", "groups": list(p.groups), "verified": p.verified, - "user_can_chat": user_can_chat()} + "user_can_chat": user_can_chat(), "personal_keys": personal_keys_enabled()} async def principal_full(session: AsyncSession, p: Principal) -> dict[str, Any]: diff --git a/backend/app/auth/keys.py b/backend/app/auth/keys.py index 665fe68c..b1d9b0a8 100644 --- a/backend/app/auth/keys.py +++ b/backend/app/auth/keys.py @@ -309,7 +309,7 @@ KEY_ROUTES: dict[tuple[str, str], KeyRoute] = { (*_APP, *_PUBLISHED, *_IDEM, "app_entry_not_found", "app_needs_upgrade", "form_invalid", KEY_FILES_UNSUPPORTED, "run_launch_refused"), idempotent=True), ("POST", "/api/apps/{app_id}/views/{view_id}/refresh"): KeyRoute( - "apps.run", "按需视图按参数运行一次(命中缓存或接上同一组参数正在跑的那次就不再发起)。必须带 Idempotency-Key;" + "apps.run", "按需视图按参数运行一次(命中缓存或接上同一组参数正在运行的那次就不再发起)。必须带 Idempotency-Key;" "沿用每个应用每天的次数上限", (*_APP, *_PUBLISHED, *_IDEM, "app_view_refused", "app_needs_upgrade", "app_view_daily_limit", "form_invalid", "run_launch_refused"), idempotent=True), diff --git a/frontend/src/api/accessKeys.ts b/frontend/src/api/accessKeys.ts new file mode 100644 index 00000000..48893913 --- /dev/null +++ b/frontend/src/api/accessKeys.ts @@ -0,0 +1,32 @@ +/** + * 访问密钥与服务账号(/api/access-keys,docs/design/next-round/04-对外集成与触达.md A1–A7),挂在 api.accessKeys 上。形状见 + * typesApp.ts。密钥明文只在建、轮换的回包里(key)出现一次,之后哪个接口都拿不到。 + */ +import type { + AccessKeyIssued, AccessKeyReference, AccessKeyRequestRow, AccessKeyRow, AccessKeyScope, AccessKeySettings, + ServiceAccountRow, +} from '../typesApp' +import { get, patch, post, put } from './http' + +const at = (id: string) => `/access-keys/${encodeURIComponent(id)}` + +export const accessKeys = { + settings: () => get('/access-keys/settings'), + /** 个人访问密钥的开关(只给管理员) */ + setPersonalKeys: (personal_keys: boolean) => put('/access-keys/settings', { personal_keys }), + /** 管理员:全部密钥;别人:自己的 */ + list: async (): Promise => (await get<{ items: AccessKeyRow[] }>('/access-keys')).items, + /** 不给 service_account_id 是建自己的个人访问密钥 */ + create: (body: { name: string; scopes: AccessKeyScope[]; app_ids?: string[] | null; expires_in_days: number; + service_account_id?: string }) => post('/access-keys', body), + revoke: (id: string) => post(`${at(id)}/revoke`), + rotate: (id: string) => post(`${at(id)}/rotate`), + requests: (id: string) => get<{ items: AccessKeyRequestRow[]; retain_days: number }>(`${at(id)}/requests`), + reference: () => get('/access-keys/reference'), + serviceAccounts: async (): Promise => + (await get<{ items: ServiceAccountRow[] }>('/access-keys/service-accounts')).items, + createServiceAccount: (body: { username: string; display_name?: string }) => + post('/access-keys/service-accounts', body), + updateServiceAccount: (id: string, body: { display_name?: string; disabled?: boolean }) => + patch(`/access-keys/service-accounts/${encodeURIComponent(id)}`, body), +} diff --git a/frontend/src/api/apps.ts b/frontend/src/api/apps.ts index 44f603c0..221d6964 100644 --- a/frontend/src/api/apps.ts +++ b/frontend/src/api/apps.ts @@ -5,7 +5,7 @@ * 入口提交校验失败回 422,ApiError 的 body 里带 {errors: FormError[]}。 */ import type { - AppBindings, AppDetail, AppGrant, AppHookRow, AppList, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult, + AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult, ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, } from '../typesApp' import type { ConversationDetail, EvidenceSegmentDetail } from '../types' @@ -72,6 +72,8 @@ export const apps = { setHookEnabled: (id: string, name: string, enabled: boolean) => patch(hookAt(id, name), { enabled }), /** 删掉这个名字的密钥行:外部系统再用旧密钥签的请求一律拒收 */ deleteHook: (id: string, name: string) => del(hookAt(id, name)), + /** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */ + hookReceipts: (id: string, name: string) => get(`${hookAt(id, name)}/receipts`), /** * 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次; * 老服务端只回对话本身时由调用方再按 id 取一次 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 079342e4..8cccd3ce 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -28,6 +28,7 @@ import { reportExport } from './reportExport' import { apps, ledger } from './apps' import { actions, tasks } from './tasks' import { auth, identity } from './auth' +import { accessKeys } from './accessKeys' import { capabilities, endpoints, notify } from './capabilities' // 普通用户建应用(typesBlueprint.ts 是接口约定) import { blueprints, files, personal, templates } from './blueprints' @@ -907,6 +908,8 @@ export const api = { actions, auth, identity, + // 访问密钥与服务账号(下一轮第 2 波) + accessKeys, capabilities, endpoints, notify, diff --git a/frontend/src/components/PrincipalPicker.tsx b/frontend/src/components/PrincipalPicker.tsx index c1b68c6e..dd2f3424 100644 --- a/frontend/src/components/PrincipalPicker.tsx +++ b/frontend/src/components/PrincipalPicker.tsx @@ -37,6 +37,11 @@ export interface PrincipalPickerProps { * 不看它 */ source?: CandidateSource + /** + * 能不能选服务账号(docs/design/next-round/04-对外集成与触达.md A1):应用授权能(外部系统按授权读应用);岗位绑定、改派 + * 不能(服务账号只持有访问密钥,回复不了待办)——缺省不能,名录里的服务账号不列、填了说清是服务账号 + */ + allowService?: boolean } /** 边输入边取候选:停手这么久再去问,不是每敲一个字问一次 */ @@ -49,15 +54,20 @@ function loadDirectory(): Promise { return directory } -/** 这一项在名录里对不上的地方:没有这个人或组(missing)、人停用了(disabled)。名录没取到、选的是所有人时不判 */ -function problemOf(p: Principal, known: Directory | null): 'missing' | 'disabled' | null { +/** + * 这一项在名录里对不上的地方:没有这个人或组(missing)、人停用了(disabled)、是服务账号而这里不收(service)。名录没取到、 + * 选的是所有人时不判 + */ +function problemOf(p: Principal, known: Directory | null, allowService: boolean): 'missing' | 'disabled' | 'service' | null { if (!known || p.kind === 'everyone') return null if (p.kind === 'group') return known.groups.some((g) => g.name === p.name) ? null : 'missing' const user = known.users.find((u) => u.username === p.name) - return !user ? 'missing' : user.disabled ? 'disabled' : null + return !user ? 'missing' : user.disabled ? 'disabled' : user.service && !allowService ? 'service' : null } -export function PrincipalPicker({ value, onChange, allowEveryone = false, single = false, label, source }: PrincipalPickerProps) { +export function PrincipalPicker({ + value, onChange, allowEveryone = false, single = false, label, source, allowService = false, +}: PrincipalPickerProps) { const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '') const loginOn = useLoginOn() // 名录给搭建者及以上(使用者读不到,不去取:取了也是 403) @@ -112,12 +122,13 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single return } } - const problem = problemOf(item, known) + const problem = problemOf(item, known, allowService) const soft = problem === 'missing' && oidc if (problem && !(soft && anyway)) { setRefused({ item, soft, - message: problem === 'disabled' ? PRINCIPAL_TEXT.disabledUser(item.name) + message: problem === 'service' ? PRINCIPAL_TEXT.serviceUser(item.name) + : problem === 'disabled' ? PRINCIPAL_TEXT.disabledUser(item.name) : soft ? PRINCIPAL_TEXT.unknownOidc(item.name) : item.kind === 'group' ? PRINCIPAL_TEXT.unknownGroup(item.name) : PRINCIPAL_TEXT.unknownUser(item.name), }) @@ -134,7 +145,8 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single ? (!pool ? [] : kind === 'user' ? pool.users.map((u) => ({ value: u.username, label: u.display_name })) : pool.groups.map((g) => ({ value: g.name, label: g.label }))) : !known ? [] : kind === 'user' - ? known.users.filter((u) => !u.disabled).map((u) => ({ value: u.username, label: u.display_name })) + ? known.users.filter((u) => !u.disabled && (allowService || !u.service)) + .map((u) => ({ value: u.username, label: u.display_name })) : known.groups.map((g) => ({ value: g.name, label: g.label })) /** 候选池要先输入开头才列(应用授权给了所有人)、列不全(超过上限) */ const poolHint = asking && pool @@ -146,7 +158,7 @@ export function PrincipalPicker({ value, onChange, allowEveryone = false, single {value.length > 0 && (
    {value.map((p) => { - const problem = problemOf(p, known) + const problem = problemOf(p, known, allowService) return (
  • ({PRINCIPAL_TEXT[p.kind]}) - {problem && · {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag : PRINCIPAL_TEXT.missingTag}} + {problem && · {problem === 'disabled' ? PRINCIPAL_TEXT.disabledTag + : problem === 'service' ? PRINCIPAL_TEXT.serviceTag : PRINCIPAL_TEXT.missingTag}} + )} {has && (