diff --git a/apps/web/src/bootstrap.spec.ts b/apps/web/src/bootstrap.spec.ts index 26a8ef7..0423fdb 100644 --- a/apps/web/src/bootstrap.spec.ts +++ b/apps/web/src/bootstrap.spec.ts @@ -15,7 +15,7 @@ vi.mock('@gocell/access', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - createPdpClient: vi.fn(() => ({ can: vi.fn() })), + createPdpClient: vi.fn(() => ({ can: vi.fn(), decide: vi.fn() })), } }) diff --git a/apps/web/src/layouts/AppShellLayout.spec.ts b/apps/web/src/layouts/AppShellLayout.spec.ts new file mode 100644 index 0000000..d99aac5 --- /dev/null +++ b/apps/web/src/layouts/AppShellLayout.spec.ts @@ -0,0 +1,111 @@ +/** + * AppShellLayout.spec.ts — PDP deny notice (aria-live region) behaviour. + * + * AppShell (from @gocell/core) is stubbed to a slot passthrough; RouterView is + * stubbed; useI18n is mocked so `te` reports which deny keys exist. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import { createPinia, setActivePinia } from 'pinia' +import { useUiStore } from '../stores/useUiStore' +import AppShellLayout from './AppShellLayout.vue' + +vi.mock('@gocell/core', () => ({ + AppShell: { name: 'AppShell', template: '
' }, +})) +vi.mock('../composables/useGlobalShortcuts', () => ({ useGlobalShortcuts: vi.fn() })) +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ + t: (k: string) => k, + // Only the two real deny keys "exist"; anything else falls back. + te: (k: string) => k === 'access.pdp.deny.role-missing' || k === 'access.pdp.deny.error', + }), +})) + +function mountLayout() { + return mount(AppShellLayout, { global: { stubs: { RouterView: true } } }) +} + +describe('AppShellLayout — PDP deny notice', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('renders no deny notice initially', () => { + expect(mountLayout().find('[role="alert"]').exists()).toBe(false) + }) + + it('announces a known deny reasonCode via an aria-live assertive alert', async () => { + const wrapper = mountLayout() + useUiStore().notifyAccessDenied('role-missing') + await nextTick() + + const alert = wrapper.find('[role="alert"]') + expect(alert.exists()).toBe(true) + expect(alert.attributes('aria-live')).toBe('assertive') + expect(alert.text()).toBe('access.pdp.deny.role-missing') + }) + + it('announces a deny reasonCode that was set before the layout mounted', async () => { + useUiStore().notifyAccessDenied('role-missing') + const wrapper = mountLayout() + await nextTick() + + expect(wrapper.find('[role="alert"]').text()).toBe('access.pdp.deny.role-missing') + }) + + it('falls back to generic deny text for an unknown reasonCode (no raw key leak)', async () => { + const wrapper = mountLayout() + useUiStore().notifyAccessDenied('policy-expired') + await nextTick() + + expect(wrapper.find('[role="alert"]').text()).toBe('access.pdp.deny.error') + }) + + it('auto-dismisses the notice after the timeout', async () => { + vi.useFakeTimers() + const wrapper = mountLayout() + const store = useUiStore() + store.notifyAccessDenied('role-missing') + await nextTick() + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + + vi.advanceTimersByTime(6000) + expect(store.accessDeniedReasonCode).toBeNull() + await nextTick() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + }) + + it('restarts dismissal for repeated deny notices with the same reasonCode', async () => { + vi.useFakeTimers() + const wrapper = mountLayout() + const store = useUiStore() + store.notifyAccessDenied('role-missing') + await nextTick() + + vi.advanceTimersByTime(5000) + store.notifyAccessDenied('role-missing') + await nextTick() + vi.advanceTimersByTime(1000) + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + + vi.advanceTimersByTime(5000) + expect(store.accessDeniedReasonCode).toBeNull() + }) + + it('clears the notice when the reasonCode resets to null', async () => { + const wrapper = mountLayout() + const store = useUiStore() + store.notifyAccessDenied('role-missing') + await nextTick() + expect(wrapper.find('[role="alert"]').exists()).toBe(true) + + store.clearAccessDenied() + await nextTick() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + }) +}) diff --git a/apps/web/src/layouts/AppShellLayout.vue b/apps/web/src/layouts/AppShellLayout.vue index 64b40d9..f91e045 100644 --- a/apps/web/src/layouts/AppShellLayout.vue +++ b/apps/web/src/layouts/AppShellLayout.vue @@ -9,15 +9,53 @@ * * The command-palette / sidebar UI state and global shortcuts live here rather * than in App.vue because they only make sense inside the shell. + * + * Access-denied notice: the route guard pushes a PDP deny reasonCode into + * useUiStore; here we localise it and render it into an aria-live region so + * screen readers announce the denial (the guard itself stays UI/i18n-free). */ +import { ref, watch, onBeforeUnmount } from 'vue' +import { useI18n } from 'vue-i18n' import { AppShell } from '@gocell/core' import { useUiStore } from '../stores/useUiStore' import { useGlobalShortcuts } from '../composables/useGlobalShortcuts' const uiStore = useUiStore() +const { t, te } = useI18n() // Registers cleanup via onScopeDispose internally — no explicit teardown needed. useGlobalShortcuts() + +// Auto-dismiss the deny notice after it has had time to be read / announced. +const DENY_DISMISS_MS = 6000 +const denyText = ref('') +let dismissTimer: ReturnType | undefined + +function resetTimer(): void { + if (dismissTimer !== undefined) { + clearTimeout(dismissTimer) + dismissTimer = undefined + } +} + +watch( + () => [uiStore.accessDeniedReasonCode, uiStore.accessDeniedNoticeSeq] as const, + ([reasonCode]) => { + resetTimer() + if (reasonCode === null) { + denyText.value = '' + return + } + // Unknown reasonCode (e.g. a future backend code without a key) → generic + // deny text rather than leaking the raw i18n key to the user. + const key = `access.pdp.deny.${reasonCode}` + denyText.value = te(key) ? t(key) : t('access.pdp.deny.error') + dismissTimer = setTimeout(() => uiStore.clearAccessDenied(), DENY_DISMISS_MS) + }, + { immediate: true }, +) + +onBeforeUnmount(resetTimer) + + diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index b6b1cfe..62eb509 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -7,9 +7,10 @@ import { createPinia } from 'pinia' import App from './App.vue' import { router } from './router' import { createGocellI18n, PDP_INJECTION_KEY } from '@gocell/core' -import { createPdpClient } from '@gocell/access' +import { createPdpClient, createHttpDecide } from '@gocell/access' import { configureAxios, bootstrapSession } from './bootstrap' import { registerGuards } from './router/guards' +import { useUiStore } from './stores/useUiStore' const app = createApp(App) @@ -23,12 +24,18 @@ configureAxios(router) app.use(createGocellI18n()) app.use(router) -// 4. PDP client provided for Can / useDecision in the whole app -const pdpClient = createPdpClient() +// 4. PDP client provided for Can / useDecision in the whole app. +// Assembly layer injects the real decision source (POST /api/v1/access/decide); +// createPdpClient keeps the cache / TTL / single-flight / fail-closed wrapper. +const pdpClient = createPdpClient({ decide: createHttpDecide() }) app.provide(PDP_INJECTION_KEY, pdpClient) -// 5. Route guards (three-stage: first-run → auth → PDP) -registerGuards(router, app, pdpClient) +// 5. Route guards (three-stage: first-run → auth → PDP). PDP deny → push the +// reasonCode into useUiStore; AppShellLayout localises it (useI18n) and +// announces it in an aria-live region. Keeps the guard free of i18n/UI. +registerGuards(router, app, pdpClient, (reasonCode: string) => { + useUiStore().notifyAccessDenied(reasonCode) +}) // 6. Attempt silent session restore before the first navigation, then mount. // No-op on a cold load until the backend ships a refresh cookie (#12 H2). diff --git a/apps/web/src/router/guards.spec.ts b/apps/web/src/router/guards.spec.ts index 8ee4c93..5b87623 100644 --- a/apps/web/src/router/guards.spec.ts +++ b/apps/web/src/router/guards.spec.ts @@ -11,7 +11,7 @@ import { createApp } from 'vue' import { http } from '@gocell/request' import { useAuthStore } from '@gocell/access' import { registerGuards, _resetSetupStatusCache } from './guards' -import type { PdpClient } from '@gocell/core' +import type { PdpClient, Decision } from '@gocell/core' import type { ComputedRef } from 'vue' /** Helper: create a valid setSession payload */ @@ -83,10 +83,17 @@ async function navigate(router: Router, to: RouteLocationRaw): Promise { await router.isReady() } -/** Build a stub PdpClient whose can() returns the given allowed value */ +/** + * Build a stub PdpClient. The PDP gate awaits decide(), so the gate verdict comes + * from decide(); can() is kept for type-completeness (unused by the guard). + */ function makePdpClient(allowed: boolean): PdpClient { return { can: vi.fn().mockReturnValue({ value: allowed } as ComputedRef), + decide: vi.fn().mockResolvedValue({ + effect: allowed ? 'allow' : 'deny', + reasonCode: allowed ? '' : 'role-missing', + } as Decision), } } @@ -308,7 +315,7 @@ describe('Router guards', () => { authStore.setSession(makeSession()) }) - it('allows access when PDP can() returns true', async () => { + it('allows access when PDP decide() resolves allow', async () => { pdpClient = makePdpClient(true) // Re-register guards with the new pdpClient _resetSetupStatusCache() @@ -328,8 +335,38 @@ describe('Router guards', () => { expect(router.currentRoute.value.name).toBe('pdp-guarded') }) - it('redirects to home when PDP can() returns false (fail-closed)', async () => { + it('awaits an async decide() before allowing — no pending→deny flicker on first nav', async () => { + // decide() resolves allow on a later tick. The guard must await it (not read a + // synchronous pending value), so first navigation to the guarded route succeeds. + const deferredAllow: PdpClient = { + can: vi.fn(), + decide: vi.fn( + () => + new Promise((resolve) => + setTimeout(() => resolve({ effect: 'allow', reasonCode: '' }), 0), + ), + ), + } + _resetSetupStatusCache() + const pinia = createPinia() + setActivePinia(pinia) + authStore = useAuthStore() + authStore.setSession(makeSession()) + const app = createApp({ template: '' }) + app.use(pinia) + router = makeRouter() + registerGuards(router, app, deferredAllow) + app.use(router) + httpGet.mockResolvedValue({ data: { data: { hasAdmin: true } } }) + + await navigate(router, '/pdp-guarded') + + expect(router.currentRoute.value.name).toBe('pdp-guarded') + }) + + it('redirects to home and surfaces the deny reasonCode when decide() denies', async () => { pdpClient = makePdpClient(false) + const onAccessDenied = vi.fn() _resetSetupStatusCache() const pinia = createPinia() setActivePinia(pinia) @@ -338,18 +375,22 @@ describe('Router guards', () => { const app = createApp({ template: '' }) app.use(pinia) router = makeRouter() - registerGuards(router, app, pdpClient) + registerGuards(router, app, pdpClient, onAccessDenied) app.use(router) httpGet.mockResolvedValue({ data: { data: { hasAdmin: true } } }) await navigate(router, '/pdp-guarded') expect(router.currentRoute.value.name).toBe('home') + expect(onAccessDenied).toHaveBeenCalledWith('role-missing') }) - it('does not run PDP gate (can not called) for routes without requiredAction', async () => { - const canSpy = vi.fn().mockReturnValue({ value: true } as ComputedRef) - pdpClient = { can: canSpy } + it('does not run PDP gate (decide not called) for routes without requiredAction', async () => { + const decideSpy = vi.fn().mockResolvedValue({ effect: 'allow', reasonCode: '' } as Decision) + pdpClient = { + can: vi.fn().mockReturnValue({ value: true } as ComputedRef), + decide: decideSpy, + } _resetSetupStatusCache() const pinia = createPinia() setActivePinia(pinia) @@ -364,7 +405,27 @@ describe('Router guards', () => { await navigate(router, '/protected') - expect(canSpy).not.toHaveBeenCalled() + expect(decideSpy).not.toHaveBeenCalled() + }) + + it('fail-closed to home and surfaces "error" when no PDP client is wired', async () => { + const onAccessDenied = vi.fn() + _resetSetupStatusCache() + const pinia = createPinia() + setActivePinia(pinia) + authStore = useAuthStore() + authStore.setSession(makeSession()) + const app = createApp({ template: '' }) + app.use(pinia) + router = makeRouter() + registerGuards(router, app, undefined, onAccessDenied) // no pdpClient + app.use(router) + httpGet.mockResolvedValue({ data: { data: { hasAdmin: true } } }) + + await navigate(router, '/pdp-guarded') + + expect(router.currentRoute.value.name).toBe('home') + expect(onAccessDenied).toHaveBeenCalledWith('error') }) }) diff --git a/apps/web/src/router/guards.ts b/apps/web/src/router/guards.ts index 3a32abd..8d2e4c3 100644 --- a/apps/web/src/router/guards.ts +++ b/apps/web/src/router/guards.ts @@ -11,7 +11,9 @@ * 的结果**;needsSetup=true 时不缓存,每次导航重新查,直到 setup 完成后某次 * 查到 false 才缓存,破除 first-run 完成后仍死循环重定向的问题。 * - auth: requiresAuth 默认 true;meta.requiresAuth === false 或 meta.public 放行 - * - PDP: fail-closed;仅当明确 allowed 时通过(ComputedRef.value 读取) + * - PDP: fail-closed;await pdpClient.decide() 拿结构化决策,仅当 effect==='allow' + * 时通过;拒绝时把 reasonCode 交给 onAccessDenied 做 i18n 提示。用 decide() 而非 + * 响应式 can():can() 首次导航 pending→false 会误把已授权用户重定向回 home。 * * 顺序安全注解(Stage 1 → Stage 2): * fetchSetupStatus 的 catch 块 return false(fail-open)是安全的, @@ -96,12 +98,21 @@ export function _resetSetupStatusCache(): void { /** * Register the three-stage beforeEach guard on the router. * - * @param router — Vue Router instance - * @param _app — Vue App instance (reserved for future app.inject() wiring) - * @param pdpClient — Optional PDP client override; used in tests. In production - * main.ts provides it via app.provide() and passes it here. + * @param router — Vue Router instance + * @param _app — Vue App instance (reserved for future app.inject() wiring) + * @param pdpClient — Optional PDP client override; used in tests. In production + * main.ts provides it via app.provide() and passes it here. + * @param onAccessDenied — Optional callback invoked with the deny reasonCode when the + * PDP gate rejects, so the assembly layer can surface an i18n + * notice. Kept out of guards.ts to keep it free of AntD / i18n + * coupling (and trivially testable in isolation). */ -export function registerGuards(router: Router, _app: App, pdpClient?: PdpClient): void { +export function registerGuards( + router: Router, + _app: App, + pdpClient?: PdpClient, + onAccessDenied?: (reasonCode: string) => void, +): void { router.beforeEach(async (to) => { // ── Stage 1: first-run gate ───────────────────────────────────────────── // PRD §5.1: needsSetup=true → always redirect to /first-run-setup, including @@ -126,21 +137,24 @@ export function registerGuards(router: Router, _app: App, pdpClient?: PdpClient) // ── Stage 3: PDP gate ─────────────────────────────────────────────────── const requiredAction = to.meta.requiredAction if (typeof requiredAction === 'string') { - // Resolve PDP client: explicit override > app global property > fail-closed - const client: PdpClient | undefined = pdpClient - - if (!client) { - // PDP client not wired yet (Batch 0 fallback) → fail-closed + if (!pdpClient) { + // PDP client not wired (Batch 0 fallback) → fail-closed if (import.meta.env.DEV) console.warn('[guards] PDP client not provided; denying access to', to.path) + onAccessDenied?.('error') return { name: 'home' } } const resource = typeof to.meta.requiredResource === 'string' ? to.meta.requiredResource : undefined - const allowed = client.can(requiredAction, resource) - if (!allowed.value) { + // Await the structured decision rather than reading the reactive can(): + // can() is pending→false on first navigation and would wrongly redirect an + // allowed user home. decide() resolves the real decision and carries the + // deny reasonCode for the i18n notice. + const decision = await pdpClient.decide(requiredAction, resource) + if (decision.effect !== 'allow') { + onAccessDenied?.(decision.reasonCode) return { name: 'home' } } } diff --git a/apps/web/src/stores/useUiStore.spec.ts b/apps/web/src/stores/useUiStore.spec.ts index 25642f6..547c340 100644 --- a/apps/web/src/stores/useUiStore.spec.ts +++ b/apps/web/src/stores/useUiStore.spec.ts @@ -41,4 +41,21 @@ describe('useUiStore', () => { store.toggleSidebar() expect(store.sidebarCollapsed).toBe(false) }) + + it('starts with accessDeniedReasonCode=null', () => { + expect(useUiStore().accessDeniedReasonCode).toBeNull() + }) + + it('notifyAccessDenied sets the reasonCode', () => { + const store = useUiStore() + store.notifyAccessDenied('role-missing') + expect(store.accessDeniedReasonCode).toBe('role-missing') + }) + + it('clearAccessDenied resets the reasonCode to null', () => { + const store = useUiStore() + store.notifyAccessDenied('error') + store.clearAccessDenied() + expect(store.accessDeniedReasonCode).toBeNull() + }) }) diff --git a/apps/web/src/stores/useUiStore.ts b/apps/web/src/stores/useUiStore.ts index 98b9592..7720ccd 100644 --- a/apps/web/src/stores/useUiStore.ts +++ b/apps/web/src/stores/useUiStore.ts @@ -4,13 +4,18 @@ import { ref } from 'vue' /** * useUiStore — global UI state for apps/web layout shell. * - * Owns commandPaletteOpen and sidebarCollapsed so that: + * Owns commandPaletteOpen / sidebarCollapsed and the access-denied notice so that: * - AppShell can v-model bind them (additive props) * - useGlobalShortcuts can read/write them without prop-drilling + * - the route guard (registerGuards' onAccessDenied) can surface a PDP deny reason + * that AppShellLayout renders into an aria-live region (decoupled from the guard) */ export const useUiStore = defineStore('web.ui', () => { const commandPaletteOpen = ref(false) const sidebarCollapsed = ref(false) + // PDP deny reasonCode pending announcement; null = nothing to announce. + const accessDeniedReasonCode = ref(null) + const accessDeniedNoticeSeq = ref(0) function openCommandPalette(): void { commandPaletteOpen.value = true @@ -24,11 +29,26 @@ export const useUiStore = defineStore('web.ui', () => { sidebarCollapsed.value = !sidebarCollapsed.value } + /** Surface a PDP deny reason (Decision.reasonCode) for the live-region notice. */ + function notifyAccessDenied(reasonCode: string): void { + accessDeniedReasonCode.value = reasonCode + accessDeniedNoticeSeq.value += 1 + } + + /** Clear the access-denied notice (after it has been announced / dismissed). */ + function clearAccessDenied(): void { + accessDeniedReasonCode.value = null + } + return { commandPaletteOpen, sidebarCollapsed, + accessDeniedReasonCode, + accessDeniedNoticeSeq, openCommandPalette, closeCommandPalette, toggleSidebar, + notifyAccessDenied, + clearAccessDenied, } }) diff --git a/packages/access/README.md b/packages/access/README.md index b772eb1..c598429 100644 --- a/packages/access/README.md +++ b/packages/access/README.md @@ -2,13 +2,13 @@ > 对应后端 cell:`cells/accesscore` -auth store(全内存 token)+ first-run / login 视图 + Identities 列表 + PDP client(fail-closed stub)的实现包。 +auth store(全内存 token)+ first-run / login 视图 + Identities 列表 + PDP client(接真实后端 `/api/v1/access/decide`)的实现包。 ## 对外 exports | 入口 | 内容 | |---|---| -| `.` (`src/index.ts`) | `useAuthStore`、`AuthUser`(type)、`createPdpClient` | +| `.` (`src/index.ts`) | `useAuthStore`、`AuthUser`(type)、`createPdpClient`、`createHttpDecide` | | `./stores` (`src/stores/index.ts`) | `useAuthStore`、`AuthUser`(type)、`useIdentitiesStore`、`usePoliciesStore`、`Role`(type) | | `./views/login` (`src/views/LoginView.vue`) | `LoginView`(默认导出,`apps/web` 路由懒加载) | | `./views/first-run` (`src/views/FirstRunSetupView.vue`) | `FirstRunSetupView`(默认导出,`apps/web` 路由懒加载) | @@ -69,14 +69,23 @@ contract 来源:`@gocell/contracts`(codegen 派生,只读)。 - **store getter**:`filteredUsers`(按 username / email 子串过滤当前已加载页) - **store read actions**:`fetchList()`(首页,replace)、`loadMore()`(cursor 续页,append;无下页或在途时 no-op);错误经 `toI18nKey` 落 `errorKey`,不抛中文字面量 - **store mutation actions**:`create` / `edit` / `lock` / `unlock` / `remove` / `changePassword`。**与读操作相反,mutation 失败时 re-throw**(由触发的 modal 内联展示并保持打开);成功后 `await fetchList()` 以列表为真相源(`changePassword` 不 refetch,行可见字段不变)。 -- **`IdentitiesView`**:`AppShell` 内子路由 `/access/identities`;hand-rolled 语义 `` + status pill + 客户端筛选 + 禁用「服务账号」tab 占位(FR-030,`aria-disabled` + `tabindex="-1"`)。行操作(create/edit/change-password/lock/unlock/delete)开 modal,每个动作按钮挂 ``(fail-closed:PDP 不允许即隐藏);路由另挂 `meta.requiredAction='read'` + `requiredResource='identity'`(guards.ts fail-closed,PDP 后端未接通前整页拒绝,见 BR-004)。 +- **`IdentitiesView`**:`AppShell` 内子路由 `/access/identities`;hand-rolled 语义 `
` + status pill + 客户端筛选 + 禁用「服务账号」tab 占位(FR-030,`aria-disabled` + `tabindex="-1"`)。行操作(create/edit/change-password/lock/unlock/delete)开 modal,每个动作按钮挂 ``(fail-closed:PDP 不允许即隐藏);路由另挂 `meta.requiredAction='read'` + `requiredResource='identity'`(guards.ts 经 `decide()` 查后端真实权限 `user:read`,fail-closed,见 BR-004)。 - **BR-005**:list 端点未交付,`api/identities` 用临时信封类型(见上「依赖的 contract」)。 -### `createPdpClient(): PdpClient` +### `createPdpClient(options?): PdpClient` -- 实现 `@gocell/core` 的 `PdpClient` interface(`PDP_INJECTION_KEY`)。 -- 在 `apps/web` 装配层 `app.provide(PDP_INJECTION_KEY, createPdpClient())` 注入(PR-06)。 -- **PDP stub 状态**:BR-004 §4.1(`/api/v1/access/decide`)后端未交付,端点 404 → fail-closed → 所有 `can()` 恒返回 `false`。缓存 + TTL(5min)+ fail-closed 逻辑已就绪;真实接通见 PR-12 / T306。 +- 实现 `@gocell/core` 的 `PdpClient` interface(`PDP_INJECTION_KEY`);持有缓存 + TTL(5min)+ 单飞 + fail-closed,与决策源解耦。 +- 决策源经 `options.decide` 注入。装配层(`apps/web/main.ts`)注入生产源 `createHttpDecide()`;未注入时 fail-closed deny-all 兜底(不 fail-open)。 + + ```ts + app.provide(PDP_INJECTION_KEY, createPdpClient({ decide: createHttpDecide() })) + ``` + +### `createHttpDecide(): DecideFn` + +- 生产决策源——接后端 `POST /api/v1/access/decide`(contract `http.auth.decide.v1`,BR-004 §4.1,gocell#1863 已上线)。 +- 把 UI (action, resource) 经 `pdp/permissionMap` 的 `toPermission` 翻译成后端注册的权限名(`:`,如 `identity` read → `user:read`、`cell` read → `system:read`),coarse 检查不传后端实例 `resource`;响应 `{ data: { allowed } }` 映射回 `Decision`。HTTP 失败(400 未注册 action / 403 / 503)→ 抛出 → client 链路 fail-closed deny。 +- 真相源:后端 `framework/pkg/authz/permission.go` 的 `allPermissions`。新增 `` 动作 / 路由 meta 资源时,无对应注册权限即 fail-closed 隐藏。 ## 边界 diff --git a/packages/access/src/components/RoleAssignmentForm.spec.ts b/packages/access/src/components/RoleAssignmentForm.spec.ts index 87365d5..1393ca2 100644 --- a/packages/access/src/components/RoleAssignmentForm.spec.ts +++ b/packages/access/src/components/RoleAssignmentForm.spec.ts @@ -16,6 +16,7 @@ const roles: Role[] = [ function makePdpClient(allowed: boolean): PdpClient { return { can: () => computed(() => allowed), + decide: () => Promise.resolve({ effect: allowed ? 'allow' : 'deny', reasonCode: '' }), } } diff --git a/packages/access/src/index.ts b/packages/access/src/index.ts index adaf4dd..cf3d4bb 100644 --- a/packages/access/src/index.ts +++ b/packages/access/src/index.ts @@ -1,3 +1,4 @@ export { useAuthStore } from './stores/useAuthStore' export type { AuthUser } from './stores/useAuthStore' export { createPdpClient } from './pdp/createPdpClient' +export { createHttpDecide } from './pdp/httpDecide' diff --git a/packages/access/src/pdp/createPdpClient.spec.ts b/packages/access/src/pdp/createPdpClient.spec.ts index cd73e5e..013db37 100644 --- a/packages/access/src/pdp/createPdpClient.spec.ts +++ b/packages/access/src/pdp/createPdpClient.spec.ts @@ -1,187 +1,230 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { flushPromises } from '@vue/test-utils' - -vi.mock('@gocell/request', () => ({ - http: { - post: vi.fn(), - }, -})) - -import { http } from '@gocell/request' +import type { Decision } from '@gocell/core' import { createPdpClient } from './createPdpClient' +import type { DecideFn } from './decideSource' + +const ALLOW: Decision = { effect: 'allow', reasonCode: '' } +const DENY: Decision = { effect: 'deny', reasonCode: 'role-missing' } +const TTL_MS = 5 * 60 * 1000 -const mockHttp = http as unknown as { post: ReturnType } +/** A vi.fn typed as a DecideFn so call assertions are available. */ +function makeDecideFn(): ReturnType & DecideFn { + return vi.fn() as unknown as ReturnType & DecideFn +} -describe('createPdpClient (fail-closed PDP stub)', () => { +describe('createPdpClient (mock-first PDP)', () => { beforeEach(() => { - vi.clearAllMocks() vi.useRealTimers() }) - afterEach(() => { vi.useRealTimers() }) - it('can() returns false initially (fail-closed during pending)', () => { - // Don't resolve the mock — keep it pending - mockHttp.post.mockReturnValue(new Promise(() => {})) - const client = createPdpClient() - const result = client.can('read', 'cells') - expect(result.value).toBe(false) - }) - - it('can() becomes true when backend responds allowed=true', async () => { - mockHttp.post.mockResolvedValueOnce({ data: { data: { allowed: true } } }) - const client = createPdpClient() - const result = client.can('read', 'cells') - - expect(result.value).toBe(false) // initially false + describe('can() — reactive boolean (fail-closed)', () => { + it('returns false initially (fail-closed during pending)', () => { + const decide = makeDecideFn() + decide.mockReturnValue(new Promise(() => {})) // never resolves + const client = createPdpClient({ decide }) + expect(client.can('read', 'cells').value).toBe(false) + }) - await flushPromises() + it('becomes true when the decision is allow', async () => { + const decide = makeDecideFn() + decide.mockResolvedValueOnce(ALLOW) + const client = createPdpClient({ decide }) + const ref = client.can('read', 'cells') - expect(result.value).toBe(true) - expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/decide', { - action: 'read', - resource: 'cells', + expect(ref.value).toBe(false) // initially pending → false + await flushPromises() + expect(ref.value).toBe(true) + expect(decide).toHaveBeenCalledWith({ action: 'read', resource: 'cells' }) }) - }) - it('can() stays false when backend responds allowed=false (fail-closed)', async () => { - mockHttp.post.mockResolvedValueOnce({ data: { data: { allowed: false } } }) - const client = createPdpClient() - const result = client.can('delete', 'cells') + it('stays false when the decision is deny (fail-closed)', async () => { + const decide = makeDecideFn() + decide.mockResolvedValueOnce(DENY) + const client = createPdpClient({ decide }) + const ref = client.can('delete', 'cells') - await flushPromises() + await flushPromises() + expect(ref.value).toBe(false) + }) - expect(result.value).toBe(false) - }) + it('stays false when the decision source rejects (fail-closed on error)', async () => { + const decide = makeDecideFn() + decide.mockRejectedValueOnce(new Error('boom')) + const client = createPdpClient({ decide }) + const ref = client.can('write', 'policy') - it('can() stays false when http.post rejects (fail-closed on error)', async () => { - mockHttp.post.mockRejectedValueOnce(new Error('network error')) - const client = createPdpClient() - const result = client.can('write', 'policies') + await flushPromises() + expect(ref.value).toBe(false) + }) - await flushPromises() + it('without resource passes resource undefined to the decision source', async () => { + const decide = makeDecideFn() + decide.mockResolvedValueOnce(ALLOW) + const client = createPdpClient({ decide }) + const ref = client.can('list') + void ref.value - expect(result.value).toBe(false) + await flushPromises() + expect(ref.value).toBe(true) + expect(decide).toHaveBeenCalledWith({ action: 'list', resource: undefined }) + }) }) - it('can() stays false when backend returns 404-like rejection (fail-closed)', async () => { - // Simulate a 404 response as a rejection - mockHttp.post.mockRejectedValueOnce({ response: { status: 404 } }) - const client = createPdpClient() - const result = client.can('admin', 'system') - - await flushPromises() - - expect(result.value).toBe(false) - }) + describe('caching', () => { + it('same key second can() within TTL does not re-decide; returns same ComputedRef', async () => { + const decide = makeDecideFn() + decide.mockResolvedValue(ALLOW) + const client = createPdpClient({ decide }) + + const ref1 = client.can('read', 'cells') + expect(ref1.value).toBe(false) + await flushPromises() + expect(ref1.value).toBe(true) + + const ref2 = client.can('read', 'cells') + expect(ref2).toBe(ref1) // identity: same ComputedRef instance + expect(ref2.value).toBe(true) + await flushPromises() + expect(decide).toHaveBeenCalledTimes(1) + }) - it('same key second can() within TTL does NOT make a second http.post call', async () => { - mockHttp.post.mockResolvedValue({ data: { data: { allowed: true } } }) - const client = createPdpClient() + it('different keys each trigger a separate decision', async () => { + const decide = makeDecideFn() + decide.mockResolvedValue(ALLOW) + const client = createPdpClient({ decide }) - const result1 = client.can('read', 'cells') - expect(result1.value).toBe(false) // read .value to trigger getter + fetch - await flushPromises() - expect(result1.value).toBe(true) // cache populated + void client.can('read', 'cells').value + await flushPromises() + void client.can('write', 'policy').value + await flushPromises() - // Second can() with same key — returns the same ComputedRef instance - const result2 = client.can('read', 'cells') - expect(result2).toBe(result1) // same ComputedRef instance (identity check) - expect(result2.value).toBe(true) // immediately true from cache - await flushPromises() + expect(decide).toHaveBeenCalledTimes(2) + }) - expect(result2.value).toBe(true) - // Only ONE network request despite two can() calls - expect(mockHttp.post).toHaveBeenCalledTimes(1) - }) + it('TTL expiry triggers a fresh decision after 5 minutes', async () => { + vi.useFakeTimers() + const decide = makeDecideFn() + decide.mockResolvedValue(ALLOW) + const client = createPdpClient({ decide }) - it('different keys each trigger a separate http.post call', async () => { - mockHttp.post.mockResolvedValue({ data: { data: { allowed: true } } }) - const client = createPdpClient() + const ref = client.can('read', 'cells') + void ref.value // pending → fires decision + await flushPromises() + expect(decide).toHaveBeenCalledTimes(1) - const result1 = client.can('read', 'cells') - void result1.value // trigger getter - await flushPromises() + expect(ref.value).toBe(true) - const result2 = client.can('write', 'policies') - void result2.value // trigger getter - await flushPromises() + // Reading the resolved value makes Vue cache the computed. TTL still has + // to invalidate that cached value without relying on Date.now() reactivity. + await vi.advanceTimersByTimeAsync(TTL_MS + 1) + expect(ref.value).toBe(false) // expired → re-fetch pending, fail-closed + await flushPromises() - expect(result1.value).toBe(true) - expect(result2.value).toBe(true) - expect(mockHttp.post).toHaveBeenCalledTimes(2) + expect(decide).toHaveBeenCalledTimes(2) + expect(ref.value).toBe(true) + }) }) - it('concurrent in-flight: two can().value calls while fetch pending only fire one http.post', async () => { - // Create a controlled promise to keep fetch in-flight - let resolveDecide!: (value: { data: { data: { allowed: boolean } } }) => void - const decidePending = new Promise<{ data: { data: { allowed: boolean } } }>((resolve) => { - resolveDecide = resolve + describe('single-flight', () => { + it('two concurrent decide() calls share one in-flight decision', async () => { + let resolve!: (d: Decision) => void + const pending = new Promise((r) => { + resolve = r + }) + const decide = makeDecideFn() + decide.mockReturnValueOnce(pending) + const client = createPdpClient({ decide }) + + const p1 = client.decide('read', 'cells') + const p2 = client.decide('read', 'cells') + expect(decide).toHaveBeenCalledTimes(1) + + resolve(ALLOW) + expect(await p1).toEqual(ALLOW) + expect(await p2).toEqual(ALLOW) + expect(decide).toHaveBeenCalledTimes(1) }) - mockHttp.post.mockReturnValueOnce(decidePending) - - const client = createPdpClient() - - // Both calls while fetch is pending - const ref1 = client.can('read', 'cells') - const ref2 = client.can('read', 'cells') - - // Both should be the same ref instance (ComputedRef cache) - expect(ref1).toBe(ref2) - // Both false while in-flight - expect(ref1.value).toBe(false) - expect(ref2.value).toBe(false) + }) - // Only one http.post fired - expect(mockHttp.post).toHaveBeenCalledTimes(1) + describe('decide() — async structured decision', () => { + it('resolves to the structured allow decision (awaits, never pending)', async () => { + const decide = makeDecideFn() + decide.mockResolvedValueOnce(ALLOW) + const client = createPdpClient({ decide }) + expect(await client.decide('read', 'cells')).toEqual(ALLOW) + }) - // Resolve the pending fetch - resolveDecide({ data: { data: { allowed: true } } }) - await flushPromises() + it('resolves to the structured deny decision with reasonCode', async () => { + const decide = makeDecideFn() + decide.mockResolvedValueOnce(DENY) + const client = createPdpClient({ decide }) + expect(await client.decide('delete', 'policy')).toEqual({ + effect: 'deny', + reasonCode: 'role-missing', + }) + }) - expect(ref1.value).toBe(true) - // Still only one http.post call total - expect(mockHttp.post).toHaveBeenCalledTimes(1) + it('maps a decision-source error to deny with reasonCode "error"', async () => { + const decide = makeDecideFn() + decide.mockRejectedValueOnce(new Error('network')) + const client = createPdpClient({ decide }) + expect(await client.decide('read', 'cells')).toEqual({ + effect: 'deny', + reasonCode: 'error', + }) + }) }) - it('TTL expiry causes a new http.post call after 5 minutes', async () => { - vi.useFakeTimers() - mockHttp.post.mockResolvedValue({ data: { data: { allowed: true } } }) - const client = createPdpClient() - - const result = client.can('read', 'cells') - void result.value // trigger getter → fires fetchDecision - // Flush pending microtasks with fake timers active - await vi.runAllTimersAsync() - - expect(mockHttp.post).toHaveBeenCalledTimes(1) + describe('shared cache between can() and decide()', () => { + it('decide() populates the cache so a later can() needs no extra decision', async () => { + const decide = makeDecideFn() + decide.mockResolvedValue(ALLOW) + const client = createPdpClient({ decide }) - // Advance past 5-minute TTL - vi.advanceTimersByTime(5 * 60 * 1000 + 1) + await client.decide('read', 'cells') + expect(decide).toHaveBeenCalledTimes(1) - // Re-read the same computed: TTL expired → should trigger new request - void result.value - await vi.runAllTimersAsync() + const ref = client.can('read', 'cells') + expect(ref.value).toBe(true) // immediately from cache, no pending flicker + await flushPromises() + expect(decide).toHaveBeenCalledTimes(1) + }) - expect(mockHttp.post).toHaveBeenCalledTimes(2) - // After re-fetch, the result is true again - expect(result.value).toBe(true) + it('can() populates the cache so a later decide() returns it without re-deciding', async () => { + const decide = makeDecideFn() + decide.mockResolvedValue(DENY) + const client = createPdpClient({ decide }) + + const ref = client.can('write', 'config') + expect(ref.value).toBe(false) // pending + await flushPromises() + expect(ref.value).toBe(false) // deny → false + expect(decide).toHaveBeenCalledTimes(1) + + expect(await client.decide('write', 'config')).toEqual({ + effect: 'deny', + reasonCode: 'role-missing', + }) + expect(decide).toHaveBeenCalledTimes(1) // served from cache + }) }) - it('can() without resource argument calls http.post with undefined resource', async () => { - mockHttp.post.mockResolvedValueOnce({ data: { data: { allowed: true } } }) - const client = createPdpClient() - const result = client.can('list') - void result.value // trigger getter - - await flushPromises() - - expect(result.value).toBe(true) - expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/decide', { - action: 'list', - resource: undefined, + describe('default decision source (no options)', () => { + it('fails closed → deny for any action when no decide source is injected', async () => { + const client = createPdpClient() + expect(await client.decide('read', 'identity')).toEqual({ + effect: 'deny', + reasonCode: 'error', + }) + + const ref = client.can('read', 'identity') + expect(ref.value).toBe(false) // first .value read triggers the lazy fetch + await flushPromises() + expect(ref.value).toBe(false) // stays denied — never fail-open }) }) }) diff --git a/packages/access/src/pdp/createPdpClient.ts b/packages/access/src/pdp/createPdpClient.ts index 8f1cd23..d302a33 100644 --- a/packages/access/src/pdp/createPdpClient.ts +++ b/packages/access/src/pdp/createPdpClient.ts @@ -1,80 +1,119 @@ /** - * BR-004 stub: /api/v1/access/decide is not yet delivered by the backend. - * Until it is, this client is fully wired (cache + TTL + fail-closed) but - * the endpoint will return 404 → fail-closed → all can() === false. - * Real integration lands in PR-12 / T306 once BR-004 §4.1 is shipped. + * PDP client(issue #50 / BR-004 §4.1)。 + * + * 决策源由装配层注入:生产用 `createHttpDecide()`(接后端 `POST /api/v1/access/decide`, + * gocell#1863 已上线),测试注入 fake。client 持有跨决策源不变的能力——响应式缓存 + + * TTL 失效 + 单飞 + 异步结构化决策 + fail-closed。未注入 `decide` 时用 deny-all 兜底 + * (fail-closed,杜绝忘记装配导致的 fail-open)。 + * + * 两条消费路径,共享同一缓存: + * - `can()`:响应式 ComputedRef,供 / useDecision;pending/error → false。 + * - `decide()`:异步结构化 Decision(含拒绝 reasonCode),供路由守卫——await 真实结果, + * 避免响应式 can() 首次导航 pending→deny 的误拦,并拿到拒绝原因用于 i18n 提示。 */ import { computed, reactive } from 'vue' import type { ComputedRef } from 'vue' -import { http } from '@gocell/request' -import type { PdpClient } from '@gocell/core' +import type { Decision, PdpClient } from '@gocell/core' +import type { DecideFn } from './decideSource' const TTL_MS = 5 * 60 * 1000 // 5 minutes +/** + * Fail-closed 默认决策源:未注入 `decide` 时一律 deny,杜绝忘记装配(生产应注入 + * `createHttpDecide()`)导致的 fail-open。reasonCode 'error' → 通用「权限校验失败」提示。 + */ +const denyAllDecide: DecideFn = () => Promise.resolve({ effect: 'deny', reasonCode: 'error' }) + interface CacheEntry { - allowed: boolean + decision: Decision fetchedAt: number } -/** - * Reactive cache: keyed by `action|resource`. - * Using a plain reactive object (record) so Vue tracks property access - * in computed() calls — reactive Map also works in Vue 3, but plain - * object property access is more predictably tracked by the scheduler. - */ interface Cache { entries: Record + expiryTick: number } function cacheKey(action: string, resource: string | undefined): string { return `${action}|${resource ?? ''}` } +export interface PdpClientOptions { + /** + * 决策源。生产由装配层注入 `createHttpDecide()`(接后端 `/api/v1/access/decide`)。 + * 省略时 fail-closed deny-all 兜底(不 fail-open)。测试可注入 fake(BR-004 §4.1)。 + */ + decide?: DecideFn +} + /** - * Create a PdpClient implementation. + * 创建 PdpClient 实现。 * - * Design: - * - Reactive cache record keyed by `action|resource`. - * - First access → fires async POST to /api/v1/access/decide; result written - * into reactive cache → all computed refs sharing the key update reactively. - * - fail-closed: initial / in-flight / error → false; only explicit - * `data.allowed === true` flips the entry to true. - * - TTL = 5 min; expired entries are deleted and re-fetched on next access. - * - In-flight guard: single concurrent request per key (no double-fire). - * - ComputedRef cache: same key always returns the same ComputedRef instance, - * preventing heap accumulation from repeated can() calls. + * 设计: + * - 响应式缓存(reactive record),key = `action|resource`。 + * - 缺失 / 过期 → 触发一次异步 `decide`;结果写回缓存 → 共享该 key 的 ComputedRef 响应式更新。 + * - fail-closed:pending / 决策源异常 → false / deny;仅明确 `allow` 才放行。 + * - TTL = 5 分钟;过期项下次访问重新取数(由 decideFn 覆写缓存项,不在 computed 内做副作用删除)。 + * - 单飞:每个 key 同一时刻仅一个在途请求,并发 can()/decide() 复用同一 Promise。 + * - ComputedRef 缓存:同 key 始终返回同一 ComputedRef 实例,避免重复 can() 调用堆积。 */ -export function createPdpClient(): PdpClient { - const store = reactive({ entries: {} }) - const inFlight = new Set() - // Cache of ComputedRef instances keyed by action|resource to avoid creating - // new computed refs on every can() call. +export function createPdpClient(options: PdpClientOptions = {}): PdpClient { + const decideFn: DecideFn = options.decide ?? denyAllDecide + const store = reactive({ entries: {}, expiryTick: 0 }) + const inFlight = new Map>() const computedCache = new Map>() + const expiryTimers = new Map>() function isExpired(entry: CacheEntry): boolean { return Date.now() - entry.fetchedAt > TTL_MS } - async function fetchDecision(action: string, resource: string | undefined): Promise { + /** 取新鲜(存在且未过期)缓存项;否则 undefined。纯读,无副作用。 */ + function freshDecision(action: string, resource: string | undefined): Decision | undefined { + const entry = store.entries[cacheKey(action, resource)] + if (!entry || isExpired(entry)) return undefined + return entry.decision + } + + function scheduleExpiryTick(key: string, fetchedAt: number): void { + const existing = expiryTimers.get(key) + if (existing !== undefined) clearTimeout(existing) + + const delay = Math.max(0, TTL_MS - (Date.now() - fetchedAt) + 1) + const timer = setTimeout(() => { + expiryTimers.delete(key) + store.expiryTick += 1 + }, delay) + expiryTimers.set(key, timer) + } + + /** 触发(或复用在途的)一次决策取数,结果写回缓存。fail-closed on error。 */ + function fetchDecision(action: string, resource: string | undefined): Promise { const key = cacheKey(action, resource) - if (inFlight.has(key)) return - inFlight.add(key) - - try { - const res = await http.post<{ data: { allowed: boolean } }>('/api/v1/access/decide', { - action, - resource, - }) - // Only true when backend explicitly says so — fail-closed - const allowed = res.data.data.allowed === true - store.entries[key] = { allowed, fetchedAt: Date.now() } - } catch { - // Network error / 404 / any failure → fail-closed - // Cache as false so repeated calls don't spam; TTL will expire it. - store.entries[key] = { allowed: false, fetchedAt: Date.now() } - } finally { - inFlight.delete(key) - } + const existing = inFlight.get(key) + if (existing) return existing + + const promise = (async (): Promise => { + try { + const decision = await decideFn({ action, resource }) + const fetchedAt = Date.now() + store.entries[key] = { decision, fetchedAt } + scheduleExpiryTick(key, fetchedAt) + return decision + } catch { + // 决策源异常 → fail-closed:缓存 deny 防止刷请求,TTL 到期后重试。 + const denied: Decision = { effect: 'deny', reasonCode: 'error' } + const fetchedAt = Date.now() + store.entries[key] = { decision: denied, fetchedAt } + scheduleExpiryTick(key, fetchedAt) + return denied + } finally { + inFlight.delete(key) + } + })() + + inFlight.set(key, promise) + return promise } function can(action: string, resource?: string): ComputedRef { @@ -83,24 +122,26 @@ export function createPdpClient(): PdpClient { if (cached) return cached const ref = computed(() => { - const entry = store.entries[key] - - if (!entry || isExpired(entry)) { - // Delete expired entry so the new fetch result wins cleanly. - if (entry && isExpired(entry)) { - delete store.entries[key] - } - // Fire side-effect; computed must be synchronous — no await. - fetchDecision(action, resource) - return false // fail-closed while pending + void store.expiryTick + const fresh = freshDecision(action, resource) + if (!fresh) { + // 缺失 / 过期 → 触发异步取数(computed 必须同步,不 await);fail-closed 返回 false。 + // fetchDecision 解析后覆写 store.entries[key],响应式触发本 computed 重算。 + void fetchDecision(action, resource) + return false } - - return entry.allowed + return fresh.effect === 'allow' }) computedCache.set(key, ref) return ref } - return { can } + async function decide(action: string, resource?: string): Promise { + const fresh = freshDecision(action, resource) + if (fresh) return fresh + return fetchDecision(action, resource) + } + + return { can, decide } } diff --git a/packages/access/src/pdp/decideSource.ts b/packages/access/src/pdp/decideSource.ts new file mode 100644 index 0000000..4f89d9c --- /dev/null +++ b/packages/access/src/pdp/decideSource.ts @@ -0,0 +1,18 @@ +import type { Decision } from '@gocell/core' + +/** + * PDP 决策入参。UI 用 (action, resource) 的细粒度词表表达授权需求;决策源 + * (生产 = httpDecide)负责把它翻译成后端注册的权限名再发 `/decide`。 + */ +export interface DecisionRequest { + action: string + // 显式允许 undefined:can()/decide() 的 resource 可省略,透传到此处(exactOptionalPropertyTypes)。 + resource?: string | undefined +} + +/** + * 决策函数签名——createPdpClient 的唯一决策源注入缝。 + * 生产由 `createHttpDecide()` 提供(接真实后端 `POST /api/v1/access/decide`); + * 测试注入 fake;未注入时 createPdpClient 用 fail-closed deny-all 兜底。 + */ +export type DecideFn = (req: DecisionRequest) => Promise diff --git a/packages/access/src/pdp/httpDecide.spec.ts b/packages/access/src/pdp/httpDecide.spec.ts new file mode 100644 index 0000000..c9985db --- /dev/null +++ b/packages/access/src/pdp/httpDecide.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import MockAdapter from 'axios-mock-adapter' +import { http } from '@gocell/request' +import { createHttpDecide, DECIDE_URL } from './httpDecide' + +describe('createHttpDecide — real PDP decision source', () => { + let mock: MockAdapter + + beforeEach(() => { + mock = new MockAdapter(http) + }) + afterEach(() => { + mock.restore() + }) + + it('POSTs the translated permission name (no backend resource for coarse checks)', async () => { + let sentBody: unknown + mock.onPost(DECIDE_URL).reply((config) => { + sentBody = JSON.parse(config.data as string) + return [200, { data: { allowed: true } }] + }) + + const decide = createHttpDecide() + await decide({ action: 'read', resource: 'identity' }) + + // identity → user; coarse page check carries no backend resource id. + expect(sentBody).toEqual({ action: 'user:read' }) + }) + + it('maps allowed=true → allow with empty reasonCode', async () => { + mock.onPost(DECIDE_URL).reply(200, { data: { allowed: true } }) + const decide = createHttpDecide() + await expect(decide({ action: 'read', resource: 'cell' })).resolves.toEqual({ + effect: 'allow', + reasonCode: '', + }) + }) + + it('maps allowed=false → deny with reasonCode role-missing (a policy deny is a 200)', async () => { + mock.onPost(DECIDE_URL).reply(200, { data: { allowed: false } }) + const decide = createHttpDecide() + await expect(decide({ action: 'write', resource: 'config' })).resolves.toEqual({ + effect: 'deny', + reasonCode: 'role-missing', + }) + }) + + it('translates write-family actions before sending (delete identity → user:write)', async () => { + let sentBody: unknown + mock.onPost(DECIDE_URL).reply((config) => { + sentBody = JSON.parse(config.data as string) + return [200, { data: { allowed: true } }] + }) + const decide = createHttpDecide() + await decide({ action: 'delete', resource: 'identity' }) + expect(sentBody).toEqual({ action: 'user:write' }) + }) + + it('rejects on HTTP error (e.g. 400 unregistered action, 503 PDP closed) for the client to fail-closed', async () => { + mock.onPost(DECIDE_URL).reply(400, { error: { code: 'ERR_AUTH_RBAC_INVALID_INPUT' } }) + const decide = createHttpDecide() + await expect(decide({ action: 'assign', resource: 'role' })).rejects.toThrow() + }) +}) diff --git a/packages/access/src/pdp/httpDecide.ts b/packages/access/src/pdp/httpDecide.ts new file mode 100644 index 0000000..b92e007 --- /dev/null +++ b/packages/access/src/pdp/httpDecide.ts @@ -0,0 +1,34 @@ +/** + * 生产 PDP 决策源——接后端 `POST /api/v1/access/decide`(http.auth.decide.v1,BR-004 §4.1)。 + * + * 注入 `createPdpClient({ decide: createHttpDecide() })` 替代占位决策源;缓存 / TTL / + * 单飞 / fail-closed 链路不变。把 UI (action, resource) 经 `toPermission` 翻译成后端注册 + * 权限名后发请求,响应 `{ data: { allowed } }` 映射回前端 `Decision`。 + * + * - 决策主体来自 JWT(请求体不带 subject);coarse 页面 / 能力检查**不传**后端 `resource` + * (实例 id),仅 ownership-scoped 检查才透传(见 contract schema note)——前端 resource + * 参数是资源「类型」,只用于拼权限名,不是实例 id。 + * - HTTP 失败(4xx/5xx,含未注册 action 的 400、PDP fail-closed 的 403/503)→ 抛出,由 + * `createPdpClient.fetchDecision` 的 catch 统一 fail-closed 成 `deny('error')`。 + */ +import { http } from '@gocell/request' +import type { HttpAuthDecideV1Request, HttpAuthDecideV1Response } from '@gocell/contracts' +import type { DecideFn } from './decideSource' +import { toPermission } from './permissionMap' + +/** PDP 决策端点(contract http.auth.decide.v1,ownerCell accesscore)。 */ +export const DECIDE_URL = '/api/v1/access/decide' + +/** + * 创建接真实后端 PDP 的决策函数。 + * 装配层注入:`createPdpClient({ decide: createHttpDecide() })`。 + */ +export function createHttpDecide(): DecideFn { + return async ({ action, resource }) => { + const body: HttpAuthDecideV1Request = { action: toPermission(action, resource) } + const res = await http.post(DECIDE_URL, body) + return res.data.data.allowed + ? { effect: 'allow', reasonCode: '' } + : { effect: 'deny', reasonCode: 'role-missing' } + } +} diff --git a/packages/access/src/pdp/index.ts b/packages/access/src/pdp/index.ts index 3ff9471..6c44929 100644 --- a/packages/access/src/pdp/index.ts +++ b/packages/access/src/pdp/index.ts @@ -1 +1,2 @@ export { createPdpClient } from './createPdpClient' +export { createHttpDecide, DECIDE_URL } from './httpDecide' diff --git a/packages/access/src/pdp/permissionMap.spec.ts b/packages/access/src/pdp/permissionMap.spec.ts new file mode 100644 index 0000000..6616f38 --- /dev/null +++ b/packages/access/src/pdp/permissionMap.spec.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { toPermission } from './permissionMap' + +describe('toPermission — UI (action, resource) → backend permission name', () => { + describe('route-gate page checks (all read) map to registered permissions', () => { + // Every value below is a registered backend permission + // (../gocell/framework/pkg/authz/permission.go). Drift here = 400 → route hidden. + it.each([ + ['read', 'identity', 'user:read'], + ['read', 'policy', 'policy:read'], + ['read', 'audit', 'audit:read'], + ['read', 'config', 'config:read'], + ['read', 'flag', 'flag:read'], + ['read', 'cell', 'system:read'], + ])('%s + %s → %s', (action, resource, expected) => { + expect(toPermission(action, resource)).toBe(expected) + }) + }) + + describe('resource-domain alias (frontend name → backend domain)', () => { + it('identity → user', () => { + expect(toPermission('create', 'identity')).toBe('user:write') + }) + it('cell → system', () => { + expect(toPermission('read', 'cell')).toBe('system:read') + }) + }) + + describe(' write-family actions collapse to the domain :write', () => { + it.each([ + ['create', 'identity', 'user:write'], + ['update', 'identity', 'user:write'], + ['delete', 'identity', 'user:write'], + ['lock', 'identity', 'user:write'], + ['unlock', 'identity', 'user:write'], + ['change-password', 'identity', 'user:write'], + ['rollback', 'config', 'config:write'], + ['delete', 'flag', 'flag:write'], + ])('%s + %s → %s', (action, resource, expected) => { + expect(toPermission(action, resource)).toBe(expected) + }) + }) + + describe('actions with a distinct registered permission keep it', () => { + it.each([ + ['write', 'config', 'config:write'], + ['publish', 'config', 'config:publish'], + ['delete', 'config', 'config:delete'], + ['write', 'flag', 'flag:write'], + ])('%s + %s → %s', (action, resource, expected) => { + expect(toPermission(action, resource)).toBe(expected) + }) + }) + + it('audit verify maps to audit:read (no separate verify permission)', () => { + expect(toPermission('verify', 'audit')).toBe('audit:read') + }) + + describe('unmapped pairs → best-effort compose (backend rejects → fail-closed)', () => { + it('role assign/revoke have no user-facing permission → composed → backend 400s', () => { + // accesscore role assign/revoke is an internal RequireCallerCell route, not a + // user permission. The composed name is unregistered → 400 → fail-closed hide. + expect(toPermission('assign', 'role')).toBe('role:assign') + expect(toPermission('revoke', 'role')).toBe('role:revoke') + }) + it('unknown resource composes ${resource}:${action}', () => { + expect(toPermission('read', 'unknown')).toBe('unknown:read') + }) + it('known resource + unknown action composes ${resource}:${action}', () => { + expect(toPermission('frobnicate', 'config')).toBe('config:frobnicate') + }) + }) + + describe('no resource → action passed through unchanged', () => { + it('a bare permission name is forwarded as-is', () => { + expect(toPermission('system:read', undefined)).toBe('system:read') + }) + it('a bare action is forwarded as-is (backend validates)', () => { + expect(toPermission('read', undefined)).toBe('read') + }) + }) +}) diff --git a/packages/access/src/pdp/permissionMap.ts b/packages/access/src/pdp/permissionMap.ts new file mode 100644 index 0000000..cf3f626 --- /dev/null +++ b/packages/access/src/pdp/permissionMap.ts @@ -0,0 +1,73 @@ +/** + * UI (action, resource) → 后端注册权限名映射(PDP adapter)。 + * + * 前端用细粒度的 (action, resource) 词表表达授权需求(如 action='create' + * resource='identity');后端 PDP 只认注册的权限名,形如 `:` + * (如 `user:write`)。本模块是两者之间的翻译层:把 UI 词表映射到后端真实权限名, + * 交给 httpDecide 发往 `/decide`。 + * + * 真相源:后端 `../gocell/framework/pkg/authz/permission.go` 的 `allPermissions`。 + * 新增 `` 动作或路由 meta 资源时,若后端有对应权限须在此登记;否则走 fail-closed。 + * + * 设计: + * - 资源域名对齐:前端 `identity`→后端 `user`、`cell`→`system`,其余同名。 + * - 动作粒度收敛:后端权限词表更粗(多数 read/write + 个别 config:publish/delete、 + * audit:export)。前端写类动作(create/update/delete/lock/unlock/change-password) + * 统一落到该域 `:write`;`verify`(审计链校验)落 `audit:read`(读侧完整性检查,后端 + * 无独立 verify 权限);`rollback` / flag `delete` 落对应域 `:write`。 + * - 未登记的 (resource, action) → best-effort 拼 `${resource}:${action}`,后端未注册该 + * 权限 → 400 → createPdpClient 链路 fail-closed deny(安全兜底)。`role` 的 assign/revoke + * 即走此路径:后端是 cell 内部路由(RequireCallerCell),无面向用户的权限 → 浏览器端 + * 不可达 → fail-closed 隐藏,符合诚实建模。 + */ +const PERMISSION_MAP: Readonly>>> = { + identity: { + read: 'user:read', + create: 'user:write', + update: 'user:write', + delete: 'user:write', + lock: 'user:write', + unlock: 'user:write', + 'change-password': 'user:write', + }, + policy: { + read: 'policy:read', + create: 'policy:write', + update: 'policy:write', + delete: 'policy:write', + write: 'policy:write', + }, + audit: { + read: 'audit:read', + verify: 'audit:read', + }, + config: { + read: 'config:read', + write: 'config:write', + publish: 'config:publish', + delete: 'config:delete', + rollback: 'config:write', + }, + flag: { + read: 'flag:read', + write: 'flag:write', + delete: 'flag:write', + }, + cell: { + read: 'system:read', + }, + role: { + read: 'role:read', + }, +} + +/** + * 把 UI (action, resource) 翻译成后端注册的权限名。 + * - 命中映射表 → 返回登记的权限名; + * - 未命中但有 resource → best-effort `${resource}:${action}`(后端拒绝 → fail-closed); + * - 无 resource → action 已是权限名(或后端将拒绝),原样透传。 + */ +export function toPermission(action: string, resource: string | undefined): string { + if (resource === undefined) return action + return PERMISSION_MAP[resource]?.[action] ?? `${resource}:${action}` +} diff --git a/packages/access/src/stores/useAuthStore.spec.ts b/packages/access/src/stores/useAuthStore.spec.ts index bf7380a..84999c4 100644 --- a/packages/access/src/stores/useAuthStore.spec.ts +++ b/packages/access/src/stores/useAuthStore.spec.ts @@ -25,6 +25,7 @@ const sessionPayload = { userId: 'user-123', passwordResetRequired: false, } +const tenantToken = 'e30.eyJ0ZW5hbnRfaWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEifQ.sig' describe('useAuthStore', () => { beforeEach(() => { @@ -44,10 +45,9 @@ describe('useAuthStore', () => { expect(store.isAuthenticated).toBe(false) expect(store.user).toBeNull() expect(store.accessToken).toBeNull() + expect(store.tenantId).toBeNull() // refreshToken is internal (write-only from outside) — not exposed on store expect(store.passwordResetRequired).toBe(false) - // tenantId has no session-contract source yet (BR-009) — null until then. - expect(store.tenantId).toBeNull() }) it('setSession sets user, accessToken and passwordResetRequired correctly', () => { @@ -57,6 +57,7 @@ describe('useAuthStore', () => { expect(store.isAuthenticated).toBe(true) expect(store.user).toEqual({ id: 'user-123' }) expect(store.accessToken).toBe('access-tok-1') + expect(store.tenantId).toBeNull() // refreshToken is internal — validated indirectly via refresh() call below expect(store.passwordResetRequired).toBe(false) }) @@ -67,6 +68,12 @@ describe('useAuthStore', () => { expect(store.passwordResetRequired).toBe(true) }) + it('setSession extracts tenant_id from the access JWT when present', () => { + const store = useAuthStore() + store.setSession({ ...sessionPayload, accessToken: tenantToken }) + expect(store.tenantId).toBe('00000000-0000-0000-0000-000000000001') + }) + it('clearSession resets all state to null/false', () => { const store = useAuthStore() store.setSession(sessionPayload) @@ -75,6 +82,7 @@ describe('useAuthStore', () => { expect(store.isAuthenticated).toBe(false) expect(store.user).toBeNull() expect(store.accessToken).toBeNull() + expect(store.tenantId).toBeNull() expect(store.passwordResetRequired).toBe(false) }) diff --git a/packages/access/src/stores/useAuthStore.ts b/packages/access/src/stores/useAuthStore.ts index 58b5106..0003658 100644 --- a/packages/access/src/stores/useAuthStore.ts +++ b/packages/access/src/stores/useAuthStore.ts @@ -24,20 +24,37 @@ const REFRESH_URL = '/api/v1/access/sessions/refresh' /** DELETE /sessions/{id} — logout revokes the current session server-side. */ const SESSION_URL = '/api/v1/access/sessions/' +function decodeJwtPayload(token: string): Record | null { + const payload = token.split('.')[1] + if (!payload) return null + try { + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + return JSON.parse(atob(padded)) as Record + } catch { + return null + } +} + +function extractTenantId(token: string): string | null { + const tenantId = decodeJwtPayload(token)?.['tenant_id'] + return typeof tenantId === 'string' && tenantId.trim() ? tenantId : null +} + export const useAuthStore = defineStore('access.auth', () => { // ─── state (all in-memory, never persisted) ─────────────────────────────── const user = ref(null) const accessToken = ref(null) + // Tenant the session belongs to — needed by tenant-scoped mutations (e.g. + // accesscore role assign/revoke). Populated from the access token's `tenant_id` + // claim in setSession (extractTenantId); stays null when the claim is absent so + // consumers guard the null case rather than send a fabricated tenant (BR-009). + const tenantId = ref(null) // refreshToken is write-only from outside; only refresh() reads it internally const _refreshToken = ref(null) // sessionId is internal; only logout() reads it to revoke the session server-side const _sessionId = ref(null) const passwordResetRequired = ref(false) - // Tenant the session belongs to — needed by tenant-scoped mutations (e.g. - // accesscore role assign/revoke). The session contract does not expose it - // yet (BR-009), so it stays null until setSession can populate it; consumers - // must guard the null case rather than send a fabricated tenant. - const tenantId = ref(null) // ─── getters ────────────────────────────────────────────────────────────── const isAuthenticated = computed(() => accessToken.value !== null) @@ -48,6 +65,7 @@ export const useAuthStore = defineStore('access.auth', () => { function setSession(payload: SessionData): void { user.value = { id: payload.userId } accessToken.value = payload.accessToken + tenantId.value = extractTenantId(payload.accessToken) _refreshToken.value = payload.refreshToken _sessionId.value = payload.sessionId passwordResetRequired.value = payload.passwordResetRequired @@ -57,10 +75,10 @@ export const useAuthStore = defineStore('access.auth', () => { function clearSession(): void { user.value = null accessToken.value = null + tenantId.value = null _refreshToken.value = null _sessionId.value = null passwordResetRequired.value = false - tenantId.value = null } /** @@ -129,8 +147,8 @@ export const useAuthStore = defineStore('access.auth', () => { // state (expose as readonly refs via Pinia's reactive proxy) user, accessToken, - passwordResetRequired, tenantId, + passwordResetRequired, // getters isAuthenticated, // actions diff --git a/packages/access/src/views/IdentitiesView.spec.ts b/packages/access/src/views/IdentitiesView.spec.ts index 744d6ad..9a457a1 100644 --- a/packages/access/src/views/IdentitiesView.spec.ts +++ b/packages/access/src/views/IdentitiesView.spec.ts @@ -9,7 +9,10 @@ import IdentitiesView from './IdentitiesView.vue' vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) -const pdp = (allowed: boolean): PdpClient => ({ can: () => computed(() => allowed) }) +const pdp = (allowed: boolean): PdpClient => ({ + can: () => computed(() => allowed), + decide: () => Promise.resolve({ effect: allowed ? 'allow' : 'deny', reasonCode: '' }), +}) const mkUser = (over: Partial = {}): Identity => ({ id: 'u-1', diff --git a/packages/access/src/views/PoliciesView.spec.ts b/packages/access/src/views/PoliciesView.spec.ts index 194d702..e6c252a 100644 --- a/packages/access/src/views/PoliciesView.spec.ts +++ b/packages/access/src/views/PoliciesView.spec.ts @@ -10,7 +10,10 @@ import PoliciesView from './PoliciesView.vue' vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) -const pdp = (allowed: boolean): PdpClient => ({ can: () => computed(() => allowed) }) +const pdp = (allowed: boolean): PdpClient => ({ + can: () => computed(() => allowed), + decide: () => Promise.resolve({ effect: allowed ? 'allow' : 'deny', reasonCode: '' }), +}) const mkRole = (over: Partial = {}): Role => ({ id: 'role-1', diff --git a/packages/audit/src/views/AuditView.spec.ts b/packages/audit/src/views/AuditView.spec.ts index 6266fcd..0dd975d 100644 --- a/packages/audit/src/views/AuditView.spec.ts +++ b/packages/audit/src/views/AuditView.spec.ts @@ -9,7 +9,10 @@ import AuditView from './AuditView.vue' vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (k: string) => k }) })) -const pdp = (allowed: boolean): PdpClient => ({ can: () => computed(() => allowed) }) +const pdp = (allowed: boolean): PdpClient => ({ + can: () => computed(() => allowed), + decide: () => Promise.resolve({ effect: allowed ? 'allow' : 'deny', reasonCode: '' }), +}) const mkEntry = (over: Partial = {}): AuditEntry => ({ id: 'evt-001', diff --git a/packages/config/src/views/ConfigView.spec.ts b/packages/config/src/views/ConfigView.spec.ts index 97ab14b..83dc383 100644 --- a/packages/config/src/views/ConfigView.spec.ts +++ b/packages/config/src/views/ConfigView.spec.ts @@ -37,7 +37,10 @@ vi.mock('../components/ConfirmDialog.vue', () => ({ }, })) -const pdp = (allowed: boolean): PdpClient => ({ can: () => computed(() => allowed) }) +const pdp = (allowed: boolean): PdpClient => ({ + can: () => computed(() => allowed), + decide: () => Promise.resolve({ effect: allowed ? 'allow' : 'deny', reasonCode: '' }), +}) const mkEntry = (over: Partial = {}): ConfigEntry => ({ id: 'cfg-1', diff --git a/packages/config/src/views/FlagsView.spec.ts b/packages/config/src/views/FlagsView.spec.ts index 91045f3..1adff17 100644 --- a/packages/config/src/views/FlagsView.spec.ts +++ b/packages/config/src/views/FlagsView.spec.ts @@ -27,7 +27,10 @@ vi.mock('../components/ConfirmDialog.vue', () => ({ }, })) -const pdp = (allowed: boolean): PdpClient => ({ can: () => computed(() => allowed) }) +const pdp = (allowed: boolean): PdpClient => ({ + can: () => computed(() => allowed), + decide: () => Promise.resolve({ effect: allowed ? 'allow' : 'deny', reasonCode: '' }), +}) const mkFlag = (over: Partial = {}): FeatureFlag => ({ id: 'flag-1', diff --git a/packages/core/src/components/Can.spec.ts b/packages/core/src/components/Can.spec.ts index 1d5fae4..de77bc1 100644 --- a/packages/core/src/components/Can.spec.ts +++ b/packages/core/src/components/Can.spec.ts @@ -6,9 +6,14 @@ import { PDP_INJECTION_KEY } from '../pdp/types' import { _resetWarnFlagForTesting } from '../pdp/useDecision' import Can from './Can.vue' +// Can consumes only can(); decide() is required by the PdpClient type but +// unused in these tests, so a constant stub keeps the literals type-complete. +const decide: PdpClient['decide'] = () => Promise.resolve({ effect: 'allow', reasonCode: '' }) + function makeMockClient(allowed: boolean): PdpClient { return { can: () => computed(() => allowed), + decide, } } @@ -152,6 +157,7 @@ describe('Can.vue', () => { // Client whose can() returns a computed backed by allowedRef const client: PdpClient = { can: () => computed(() => allowedRef.value), + decide, } const Wrapper = defineComponent({ diff --git a/packages/core/src/i18n/messages/en-US.ts b/packages/core/src/i18n/messages/en-US.ts index c546320..51c31eb 100644 --- a/packages/core/src/i18n/messages/en-US.ts +++ b/packages/core/src/i18n/messages/en-US.ts @@ -107,6 +107,13 @@ const enUS = { resultsLabel: 'Search results', }, access: { + pdp: { + // Route PDP gate deny notice; key suffix = Decision.reasonCode + deny: { + 'role-missing': "You don't have permission to access this page", + error: 'Permission check failed, please try again', + }, + }, login: { title: 'Sign in', subtitle: 'Sign in to the console with your admin account', diff --git a/packages/core/src/i18n/messages/zh-CN.ts b/packages/core/src/i18n/messages/zh-CN.ts index 520aef7..012fa57 100644 --- a/packages/core/src/i18n/messages/zh-CN.ts +++ b/packages/core/src/i18n/messages/zh-CN.ts @@ -110,6 +110,13 @@ const zhCN = { resultsLabel: '搜索结果', }, access: { + pdp: { + // 路由 PDP 网关拒绝时的提示,key 后缀 = Decision.reasonCode + deny: { + 'role-missing': '您没有访问该页面的权限', + error: '权限校验失败,请稍后重试', + }, + }, login: { title: '登录', subtitle: '使用管理员账号登录控制台', diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f482381..b390f25 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,7 +9,7 @@ export type { AppLocale } from './stores/index' // PDP export { useDecision, PDP_INJECTION_KEY } from './pdp/index' -export type { PdpClient } from './pdp/index' +export type { PdpClient, Decision, DecisionEffect } from './pdp/index' // Components export { Can, UnavailablePanel } from './components/index' diff --git a/packages/core/src/pdp/index.ts b/packages/core/src/pdp/index.ts index ee292f9..d349e94 100644 --- a/packages/core/src/pdp/index.ts +++ b/packages/core/src/pdp/index.ts @@ -1,3 +1,3 @@ export { useDecision } from './useDecision' export { PDP_INJECTION_KEY } from './types' -export type { PdpClient } from './types' +export type { PdpClient, Decision, DecisionEffect } from './types' diff --git a/packages/core/src/pdp/types.ts b/packages/core/src/pdp/types.ts index 48a98c3..346be3d 100644 --- a/packages/core/src/pdp/types.ts +++ b/packages/core/src/pdp/types.ts @@ -1,15 +1,44 @@ import type { ComputedRef, InjectionKey } from 'vue' +/** + * 决策效果。对标 BR-004 §3.2 `decision` 三态的前端最小子集: + * MVP 阶段 `not-applicable`(无适用策略)统一并入 `deny`,接口字段先不暴露, + * 待 Wave 2 决策日志需要区分"被拒"与"问错了"时再扩。 + */ +export type DecisionEffect = 'allow' | 'deny' + +/** + * 结构化授权决策。对标 BR-004 §3.2 `Decision` 的前端消费子集—— + * 只保留前端当前真正消费的字段(effect + reasonCode);matchedPolicies / + * decisionId / evaluatedAtMs 等待有消费方时再加(不预设未来需求)。 + */ +export interface Decision { + /** `allow` 明确允许;`deny` 拒绝。 */ + effect: DecisionEffect + /** + * 拒绝原因的 i18n key 后缀(如 `role-missing` / `error`)。 + * 消费方拼 `access.pdp.deny.` 取本地化文案。 + * `allow` 时为空串。 + */ + reasonCode: string +} + /** * PDP 客户端契约;实现在 @gocell/access。 * @gocell/core 只持有 UI 壳 + 注入契约,不含业务逻辑。 */ export interface PdpClient { /** - * 响应式、fail-closed:仅当后端明确 allow 时为 true。 - * pending / error → false(绝不 fail-open)。 + * 响应式、fail-closed:仅当决策明确 allow 时为 true。 + * pending / error → false(绝不 fail-open)。供 / useDecision 消费。 */ can(action: string, resource?: string): ComputedRef + /** + * 异步结构化决策:await 真实决策结果(含拒绝 reasonCode)。 + * 路由守卫专用——避免响应式 `can()` 首次导航 pending→deny 的误拦, + * 并拿到拒绝原因用于 i18n 提示。结果与 `can()` 共享同一缓存。 + */ + decide(action: string, resource?: string): Promise } /** diff --git a/packages/core/src/pdp/useDecision.spec.ts b/packages/core/src/pdp/useDecision.spec.ts index 83eeff9..d7a4891 100644 --- a/packages/core/src/pdp/useDecision.spec.ts +++ b/packages/core/src/pdp/useDecision.spec.ts @@ -5,6 +5,10 @@ import type { PdpClient } from './types' import { PDP_INJECTION_KEY } from './types' import { useDecision, _resetWarnFlagForTesting } from './useDecision' +// useDecision consumes only can(); decide() is required by the PdpClient type +// but unused in these tests, so a constant stub keeps the literals type-complete. +const decide: PdpClient['decide'] = () => Promise.resolve({ effect: 'allow', reasonCode: '' }) + /** * Vue's provide/inject is parent→child only. * We need Outer (provides) → Inner (injects via useDecision). @@ -103,6 +107,7 @@ describe('useDecision', () => { it('returns true when provider.can() returns a computed true', () => { const client: PdpClient = { can: () => computed(() => true), + decide, } const result = createDecisionTest( client, @@ -117,6 +122,7 @@ describe('useDecision', () => { it('returns false when provider.can() returns a computed false', () => { const client: PdpClient = { can: () => computed(() => false), + decide, } const result = createDecisionTest( client, @@ -132,6 +138,7 @@ describe('useDecision', () => { const action = ref('denied-action') const client: PdpClient = { can: (a) => computed(() => a === 'allowed-action'), + decide, } let result: ReturnType | undefined @@ -167,6 +174,7 @@ describe('useDecision', () => { const resource = ref('res:denied') const client: PdpClient = { can: (_action, res) => computed(() => res === 'res:allowed'), + decide, } let result: ReturnType | undefined @@ -201,6 +209,7 @@ describe('useDecision', () => { it('does not emit our warn when provider is present', () => { const client: PdpClient = { can: () => computed(() => true), + decide, } createDecisionTest(client, () => 'read') const ourWarns = getOurWarns(warnSpy) diff --git a/tools/cell-manifest/src/index.ts b/tools/cell-manifest/src/index.ts index 8f8828e..f65a85d 100644 --- a/tools/cell-manifest/src/index.ts +++ b/tools/cell-manifest/src/index.ts @@ -26,7 +26,7 @@ function main(): void { throw new Error( `Cell source directory not found: ${CELLS_DIR}\n` + `Check out the backend repo ghbvf/gocell as a sibling of this repo, ` + - `or set GOCELL_CELLS_DIR to point to the cells directory.`, + `or set GOCELL_CELLS_DIR to point to the corecells directory.`, ) }