From 599d88d1767a763434db2ae533f77d2248457d48 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:05:17 +0800 Subject: [PATCH 1/7] feat(core): structured PDP Decision + PdpClient.decide() + deny i18n keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PdpClient 增加异步结构化 decide(action,resource):Promise,与响应式 can() 共享语义。新增 Decision { effect: 'allow'|'deny'; reasonCode } —— 对标 BR-004 §3.2 的前端消费子集。新增 access.pdp.deny.* i18n 键(role-missing / error) 供路由守卫本地化拒绝提示。 Refs #50 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/components/Can.spec.ts | 6 +++++ packages/core/src/i18n/messages/en-US.ts | 7 +++++ packages/core/src/i18n/messages/zh-CN.ts | 7 +++++ packages/core/src/index.ts | 2 +- packages/core/src/pdp/index.ts | 2 +- packages/core/src/pdp/types.ts | 33 +++++++++++++++++++++-- packages/core/src/pdp/useDecision.spec.ts | 9 +++++++ 7 files changed, 62 insertions(+), 4 deletions(-) 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 8869e34..9c96caa 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 7f87eb8..e32273b 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) From adaf01178dcb72bea53b615dc4d25392eb7047e4 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:05:33 +0800 Subject: [PATCH 2/7] feat(access): mock-first PDP decision source replacing fail-closed stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createPdpClient 不再永久 fail-closed(后端 /access/decide 未上线,gocell#1863)。 按 epic #62「mock-first」原则注入确定性 RBAC mock 决策源(mockDecide),默认 ADMIN_GRANT 全量放行 → 全站路由网关恢复可达;受限 grant(VIEWER_GRANT)驱动真实 deny 路径。client 保留缓存 + TTL + 单飞 + fail-closed,并实现 decide() 异步结构化决策。 后端端点交付后仅需 createPdpClient({ decide: realFn }) 注入即可替换。 Refs #50 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/RoleAssignmentForm.spec.ts | 1 + .../access/src/pdp/createPdpClient.spec.ts | 322 ++++++++++-------- packages/access/src/pdp/createPdpClient.ts | 136 ++++---- packages/access/src/pdp/mockDecide.spec.ts | 93 +++++ packages/access/src/pdp/mockDecide.ts | 71 ++++ .../access/src/views/IdentitiesView.spec.ts | 5 +- .../access/src/views/PoliciesView.spec.ts | 5 +- 7 files changed, 429 insertions(+), 204 deletions(-) create mode 100644 packages/access/src/pdp/mockDecide.spec.ts create mode 100644 packages/access/src/pdp/mockDecide.ts 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/pdp/createPdpClient.spec.ts b/packages/access/src/pdp/createPdpClient.spec.ts index cd73e5e..9ff378a 100644 --- a/packages/access/src/pdp/createPdpClient.spec.ts +++ b/packages/access/src/pdp/createPdpClient.spec.ts @@ -1,187 +1,225 @@ 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 './mockDecide' + +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 }) + + const ref = client.can('read', 'cells') + void ref.value // pending → fires decision + await vi.runAllTimersAsync() + expect(decide).toHaveBeenCalledTimes(1) + + // Advance past TTL without reading in between, so the computed stays dirty + // (invalidated by the resolve write) and re-runs its getter on next read. + vi.advanceTimersByTime(TTL_MS + 1) + void ref.value // re-run getter → expired → fresh decision + await vi.runAllTimersAsync() + + expect(decide).toHaveBeenCalledTimes(2) + expect(ref.value).toBe(true) + }) }) - it('different keys each trigger a separate http.post call', async () => { - mockHttp.post.mockResolvedValue({ data: { data: { allowed: true } } }) - const client = createPdpClient() - - const result1 = client.can('read', 'cells') - void result1.value // trigger getter - await flushPromises() - - const result2 = client.can('write', 'policies') - void result2.value // trigger getter - await flushPromises() - - expect(result1.value).toBe(true) - expect(result2.value).toBe(true) - expect(mockHttp.post).toHaveBeenCalledTimes(2) + 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) + }) }) - 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('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) }) - 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) - - // 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() + 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 }) - expect(mockHttp.post).toHaveBeenCalledTimes(1) + await client.decide('read', 'cells') + expect(decide).toHaveBeenCalledTimes(1) - // Advance past 5-minute TTL - vi.advanceTimersByTime(5 * 60 * 1000 + 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() + describe('default decision source (no options)', () => { + it('uses the admin mock → allow for any action/resource', async () => { + const client = createPdpClient() + expect((await client.decide('delete', 'config')).effect).toBe('allow') - expect(result.value).toBe(true) - expect(mockHttp.post).toHaveBeenCalledWith('/api/v1/access/decide', { - action: 'list', - resource: undefined, + const ref = client.can('read', 'identity') + expect(ref.value).toBe(false) // first .value read triggers the lazy fetch + await flushPromises() + expect(ref.value).toBe(true) }) }) }) diff --git a/packages/access/src/pdp/createPdpClient.ts b/packages/access/src/pdp/createPdpClient.ts index 8f1cd23..bc43109 100644 --- a/packages/access/src/pdp/createPdpClient.ts +++ b/packages/access/src/pdp/createPdpClient.ts @@ -1,27 +1,28 @@ /** - * 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. + * Mock-first PDP client(issue #50 / BR-004 §4.1)。 + * + * 决策源默认走本地 mock(createMockDecide)——后端 `POST /api/v1/access/decide` + * 尚未上线(gocell#1863)。按 epic #62「mock-first」原则前端不阻塞:client 全链路 + * 就绪——响应式缓存 + TTL 失效 + 单飞 + 异步结构化决策 + fail-closed。后端端点交付后 + * 仅需 `createPdpClient({ decide: realDecideFn })` 注入对真实端点的调用,其余不变。 + * + * 两条消费路径,共享同一缓存: + * - `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 { createMockDecide, type DecideFn } from './mockDecide' const TTL_MS = 5 * 60 * 1000 // 5 minutes 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 } @@ -30,51 +31,65 @@ function cacheKey(action: string, resource: string | undefined): string { return `${action}|${resource ?? ''}` } +export interface PdpClientOptions { + /** + * 决策源。默认 mock-first 解析器(createMockDecide())。 + * 后端 `POST /api/v1/access/decide` 上线后,注入对真实端点的调用即可替换(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 { +export function createPdpClient(options: PdpClientOptions = {}): PdpClient { + const decideFn: DecideFn = options.decide ?? createMockDecide() 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. + const inFlight = new Map>() const computedCache = 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 + } + + /** 触发(或复用在途的)一次决策取数,结果写回缓存。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 }) + store.entries[key] = { decision, fetchedAt: Date.now() } + return decision + } catch { + // 决策源异常 → fail-closed:缓存 deny 防止刷请求,TTL 到期后重试。 + const denied: Decision = { effect: 'deny', reasonCode: 'error' } + store.entries[key] = { decision: denied, fetchedAt: Date.now() } + return denied + } finally { + inFlight.delete(key) + } + })() + + inFlight.set(key, promise) + return promise } function can(action: string, resource?: string): ComputedRef { @@ -83,24 +98,25 @@ 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 + 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/mockDecide.spec.ts b/packages/access/src/pdp/mockDecide.spec.ts new file mode 100644 index 0000000..826e5fd --- /dev/null +++ b/packages/access/src/pdp/mockDecide.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { ADMIN_GRANT, VIEWER_GRANT, evaluateGrant, createMockDecide } from './mockDecide' + +describe('mockDecide — deterministic RBAC mock decision source', () => { + describe('evaluateGrant', () => { + it('ADMIN_GRANT (*:*) allows any action on any resource', () => { + expect(evaluateGrant(ADMIN_GRANT, { action: 'read', resource: 'identity' })).toEqual({ + effect: 'allow', + reasonCode: '', + }) + expect(evaluateGrant(ADMIN_GRANT, { action: 'delete', resource: 'config' })).toEqual({ + effect: 'allow', + reasonCode: '', + }) + }) + + it('ADMIN_GRANT allows an action with no resource', () => { + expect(evaluateGrant(ADMIN_GRANT, { action: 'list' })).toEqual({ + effect: 'allow', + reasonCode: '', + }) + }) + + it('VIEWER_GRANT (read:*) allows read on any resource', () => { + expect(evaluateGrant(VIEWER_GRANT, { action: 'read', resource: 'audit' })).toEqual({ + effect: 'allow', + reasonCode: '', + }) + }) + + it('VIEWER_GRANT denies write/delete with reasonCode role-missing', () => { + expect(evaluateGrant(VIEWER_GRANT, { action: 'write', resource: 'config' })).toEqual({ + effect: 'deny', + reasonCode: 'role-missing', + }) + expect(evaluateGrant(VIEWER_GRANT, { action: 'delete', resource: 'identity' })).toEqual({ + effect: 'deny', + reasonCode: 'role-missing', + }) + }) + + it('matches an exact action:resource rule', () => { + const grant = ['write:config'] as const + expect(evaluateGrant(grant, { action: 'write', resource: 'config' }).effect).toBe('allow') + expect(evaluateGrant(grant, { action: 'write', resource: 'flag' }).effect).toBe('deny') + expect(evaluateGrant(grant, { action: 'read', resource: 'config' }).effect).toBe('deny') + }) + + it('action wildcard matches any action on a fixed resource', () => { + const grant = ['*:config'] as const + expect(evaluateGrant(grant, { action: 'read', resource: 'config' }).effect).toBe('allow') + expect(evaluateGrant(grant, { action: 'delete', resource: 'config' }).effect).toBe('allow') + expect(evaluateGrant(grant, { action: 'read', resource: 'flag' }).effect).toBe('deny') + }) + + it('empty grant denies everything', () => { + expect(evaluateGrant([], { action: 'read', resource: 'cell' })).toEqual({ + effect: 'deny', + reasonCode: 'role-missing', + }) + }) + + it('ignores malformed rules without a colon (fail-closed)', () => { + const grant = ['read'] as const // no ':' → never matches + expect(evaluateGrant(grant, { action: 'read', resource: '' }).effect).toBe('deny') + }) + + it('a rule with an empty resource matches a request with no resource', () => { + const grant = ['read:'] as const + expect(evaluateGrant(grant, { action: 'read' }).effect).toBe('allow') + expect(evaluateGrant(grant, { action: 'read', resource: 'cell' }).effect).toBe('deny') + }) + }) + + describe('createMockDecide', () => { + it('defaults to the admin grant → allow', async () => { + const decide = createMockDecide() + expect(await decide({ action: 'delete', resource: 'config' })).toEqual({ + effect: 'allow', + reasonCode: '', + }) + }) + + it('honors a custom grant → deny for ungranted actions', async () => { + const decide = createMockDecide(VIEWER_GRANT) + expect(await decide({ action: 'write', resource: 'flag' })).toEqual({ + effect: 'deny', + reasonCode: 'role-missing', + }) + expect((await decide({ action: 'read', resource: 'flag' })).effect).toBe('allow') + }) + }) +}) diff --git a/packages/access/src/pdp/mockDecide.ts b/packages/access/src/pdp/mockDecide.ts new file mode 100644 index 0000000..6c86761 --- /dev/null +++ b/packages/access/src/pdp/mockDecide.ts @@ -0,0 +1,71 @@ +import type { Decision } from '@gocell/core' + +/** + * Mock-first PDP 决策源——替代尚未上线的后端 `POST /api/v1/access/decide` + * (BR-004 §4.1,后端跟踪 gocell#1863)。 + * + * 前端按 epic #62「mock-first」原则不等后端:本模块用确定性 RBAC grant 评估 + * (action, resource),返回 BR-004 §3.2 `Decision` 的前端消费子集。后端端点交付后, + * 把 createPdpClient 的默认 `decide` 换成对真实端点的调用(响应映射回 `Decision`)即可, + * 本模块整体删除——其余 PDP 链路(缓存 / TTL / 守卫 / )不变。 + */ + +/** PDP 决策入参。对标 BR-004 §3.1 请求的 MVP 子集(仅 action + 可选 resource)。 */ +export interface DecisionRequest { + action: string + // 显式允许 undefined:can()/decide() 的 resource 可省略,透传到此处(exactOptionalPropertyTypes)。 + resource?: string | undefined +} + +/** + * 决策函数签名。注入到 createPdpClient,是「mock ↔ 真实后端」的唯一替换缝。 + */ +export type DecideFn = (req: DecisionRequest) => Promise + +/** + * 授权 grant:`":"` 规则集合,`*` 为通配。 + * 形如 `"*:*"`(全放行)/ `"read:*"`(只读全资源)/ `"write:config"`(精确)。 + * 命中任一规则即 allow。 + */ +export type Grant = readonly string[] + +/** + * MVP 默认主体 = first-run 创建的唯一 admin → 全量授权。 + * 单管理员部署下管理员本就该看到一切,故默认 grant-all 是诚实建模而非"假放行"; + * deny 分支由受限 grant(如 VIEWER_GRANT)在测试 / 演示中真实触发。 + */ +export const ADMIN_GRANT: Grant = ['*:*'] + +/** 只读演示 grant:仅 read 全资源;写操作 → deny(驱动 隐藏写按钮)。 */ +export const VIEWER_GRANT: Grant = ['read:*'] + +/** 单条 grant 规则是否命中 (action, resource)。非法规则(无 `:`)→ 不匹配(fail-closed)。 */ +function ruleMatches(rule: string, action: string, resource: string): boolean { + const sep = rule.indexOf(':') + if (sep < 0) return false + const ruleAction = rule.slice(0, sep) + const ruleResource = rule.slice(sep + 1) + const actionOk = ruleAction === '*' || ruleAction === action + const resourceOk = ruleResource === '*' || ruleResource === resource + return actionOk && resourceOk +} + +/** + * 用 grant 评估 (action, resource) → 结构化 Decision。 + * 命中 → allow;未命中 → deny(reasonCode `role-missing`,对应 i18n 拒绝文案)。 + */ +export function evaluateGrant(grant: Grant, req: DecisionRequest): Decision { + const resource = req.resource ?? '' + const allowed = grant.some((rule) => ruleMatches(rule, req.action, resource)) + return allowed + ? { effect: 'allow', reasonCode: '' } + : { effect: 'deny', reasonCode: 'role-missing' } +} + +/** + * 创建 mock 决策函数。默认 ADMIN_GRANT(MVP 单管理员 → 全量放行,全站路由可达)。 + * 测试 / 演示传入受限 grant 即可驱动真实 deny 路径。 + */ +export function createMockDecide(grant: Grant = ADMIN_GRANT): DecideFn { + return (req) => Promise.resolve(evaluateGrant(grant, req)) +} 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 15e5988..92c5eca 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', From e6eb4ca0adccf0639afa057a0b0b844a63f75885 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:05:52 +0800 Subject: [PATCH 3/7] feat(web): PDP route gate via decide() with i18n deny notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 路由守卫 PDP 段改 await pdpClient.decide()(替代读响应式 can()),修复首次导航 pending→false 误把已授权用户重定向回 home 的潜在缺陷;拒绝时把 reasonCode 交给 注入的 onAccessDenied 回调。main.ts 装配层用 AntD message.warning + i18n 本地化 拒绝提示,守卫本身不耦合 AntD / i18n。 Refs #50 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/bootstrap.spec.ts | 2 +- apps/web/src/main.ts | 16 ++++-- apps/web/src/router/guards.spec.ts | 79 ++++++++++++++++++++++++++---- apps/web/src/router/guards.ts | 40 ++++++++++----- 4 files changed, 110 insertions(+), 27 deletions(-) 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/main.ts b/apps/web/src/main.ts index b6b1cfe..9386c5c 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -4,6 +4,7 @@ import '@gocell/core/styles/v1-linear.scss' import { createApp } from 'vue' import { createPinia } from 'pinia' +import { message } from 'ant-design-vue' import App from './App.vue' import { router } from './router' import { createGocellI18n, PDP_INJECTION_KEY } from '@gocell/core' @@ -19,16 +20,23 @@ app.use(createPinia()) // 2. Axios: auth callbacks wired to authStore configureAxios(router) -// 3. i18n + router -app.use(createGocellI18n()) +// 3. i18n + router (capture i18n so the route guard can localise deny reasons) +const i18n = createGocellI18n() +app.use(i18n) app.use(router) // 4. PDP client provided for Can / useDecision in the whole app const pdpClient = createPdpClient() 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 → localised warning toast. The translate fn is cast through a +// minimal signature because createGocellI18n's broad return type leaves +// i18n.global.t as a union of overloads (assembly-layer adapter only). +const translate = i18n.global.t as unknown as (key: string) => string +registerGuards(router, app, pdpClient, (reasonCode: string) => { + message.warning(translate(`access.pdp.deny.${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' } } } From 483d855343cdcfefdc42dd7afe47c2a16c27e53a Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:05:56 +0800 Subject: [PATCH 4/7] test(audit,config): satisfy extended PdpClient stub (decide) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PdpClient 新增 decide()(#50)→ 各视图测试的 PdpClient stub 补上 decide 成员以满足 类型完整性;测试行为不变。 Refs #50 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/audit/src/views/AuditView.spec.ts | 5 ++++- packages/config/src/views/ConfigView.spec.ts | 5 ++++- packages/config/src/views/FlagsView.spec.ts | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/audit/src/views/AuditView.spec.ts b/packages/audit/src/views/AuditView.spec.ts index 3aae128..7c2f5c6 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', From 45401a2a64ecdcd38da7352d6e1aa4f6a5d09a7e Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:18:50 +0800 Subject: [PATCH 5/7] fix(web): a11y-correct PDP deny notice via aria-live region (review Cx2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三维度 review 收口:原 deny 提示走 AntD message 静态 API 有三个问题——脱离 ConfigProvider 主题树、无 aria-live/role=alert(WCAG 4.1.3 屏阅不可感知)、main.ts 需 `i18n.global.t as unknown as` 双重强转。改为:守卫经 onAccessDenied 把 reasonCode 推入 useUiStore,AppShellLayout 用 useI18n 本地化并渲染到 role="alert" aria-live 区域(tokens-only 样式 + 6s 自动消除)。未知 reasonCode 经 te() 回退到通用文案, 不再泄漏裸 i18n key。守卫保持 UI/i18n 解耦,main.ts 去掉 AntD message + 强转。 Refs #50 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/layouts/AppShellLayout.spec.ts | 86 +++++++++++++++++++++ apps/web/src/layouts/AppShellLayout.vue | 65 ++++++++++++++++ apps/web/src/main.ts | 17 ++-- apps/web/src/stores/useUiStore.spec.ts | 17 ++++ apps/web/src/stores/useUiStore.ts | 19 ++++- 5 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/layouts/AppShellLayout.spec.ts diff --git a/apps/web/src/layouts/AppShellLayout.spec.ts b/apps/web/src/layouts/AppShellLayout.spec.ts new file mode 100644 index 0000000..1b0093c --- /dev/null +++ b/apps/web/src/layouts/AppShellLayout.spec.ts @@ -0,0 +1,86 @@ +/** + * 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('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('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..1cedc9b 100644 --- a/apps/web/src/layouts/AppShellLayout.vue +++ b/apps/web/src/layouts/AppShellLayout.vue @@ -9,15 +9,52 @@ * * 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, + (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) + }, +) + +onBeforeUnmount(resetTimer) + + diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 9386c5c..9fe0492 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -4,13 +4,13 @@ import '@gocell/core/styles/v1-linear.scss' import { createApp } from 'vue' import { createPinia } from 'pinia' -import { message } from 'ant-design-vue' import App from './App.vue' import { router } from './router' import { createGocellI18n, PDP_INJECTION_KEY } from '@gocell/core' import { createPdpClient } from '@gocell/access' import { configureAxios, bootstrapSession } from './bootstrap' import { registerGuards } from './router/guards' +import { useUiStore } from './stores/useUiStore' const app = createApp(App) @@ -20,22 +20,19 @@ app.use(createPinia()) // 2. Axios: auth callbacks wired to authStore configureAxios(router) -// 3. i18n + router (capture i18n so the route guard can localise deny reasons) -const i18n = createGocellI18n() -app.use(i18n) +// 3. i18n + router +app.use(createGocellI18n()) app.use(router) // 4. PDP client provided for Can / useDecision in the whole app const pdpClient = createPdpClient() app.provide(PDP_INJECTION_KEY, pdpClient) -// 5. Route guards (three-stage: first-run → auth → PDP). -// PDP deny → localised warning toast. The translate fn is cast through a -// minimal signature because createGocellI18n's broad return type leaves -// i18n.global.t as a union of overloads (assembly-layer adapter only). -const translate = i18n.global.t as unknown as (key: string) => string +// 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) => { - message.warning(translate(`access.pdp.deny.${reasonCode}`)) + useUiStore().notifyAccessDenied(reasonCode) }) // 6. Attempt silent session restore before the first navigation, then mount. 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..e4bc1be 100644 --- a/apps/web/src/stores/useUiStore.ts +++ b/apps/web/src/stores/useUiStore.ts @@ -4,13 +4,17 @@ 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) function openCommandPalette(): void { commandPaletteOpen.value = true @@ -24,11 +28,24 @@ 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 + } + + /** Clear the access-denied notice (after it has been announced / dismissed). */ + function clearAccessDenied(): void { + accessDeniedReasonCode.value = null + } + return { commandPaletteOpen, sidebarCollapsed, + accessDeniedReasonCode, openCommandPalette, closeCommandPalette, toggleSidebar, + notifyAccessDenied, + clearAccessDenied, } }) From 67e1540bd2ce182ed83aa818c793e2a3cbb41938 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:14:40 +0800 Subject: [PATCH 6/7] fix: address PDP review and readonly CI drift --- .github/workflows/cell-manifest-diff.yml | 5 +- apps/web/src/layouts/AppShellLayout.spec.ts | 25 ++ apps/web/src/layouts/AppShellLayout.vue | 5 +- apps/web/src/stores/useUiStore.ts | 3 + packages/access/src/api/roles.spec.ts | 14 +- .../access/src/pdp/createPdpClient.spec.ts | 14 +- packages/access/src/pdp/createPdpClient.ts | 25 +- .../access/src/stores/useAuthStore.spec.ts | 10 + packages/access/src/stores/useAuthStore.ts | 21 ++ .../src/stores/usePoliciesStore.spec.ts | 46 ++- .../access/src/stores/usePoliciesStore.ts | 13 +- .../http/admin/health/cells/v1/response.ts | 61 ++++ .../src/http/audit/list/v1/response.ts | 12 +- .../src/http/auth/refresh/v1/request.ts | 2 +- .../src/http/auth/role/assign/v1/request.ts | 1 + .../src/http/auth/role/revoke/v1/request.ts | 1 + .../src/http/devicecompliance/v1/response.ts | 43 +++ .../http/deviceidentity/enroll/v1/request.ts | 292 +++++++++++++++++ .../http/deviceidentity/enroll/v1/response.ts | 44 +++ .../http/deviceidentity/renew/v1/request.ts | 293 ++++++++++++++++++ .../http/deviceidentity/renew/v1/response.ts | 48 +++ .../http/deviceidentity/revoke/v1/request.ts | 37 +++ .../http/deviceidentity/revoke/v1/response.ts | 40 +++ .../http/deviceidentity/status/v1/response.ts | 31 ++ .../src/http/devicestate/v1/response.ts | 19 ++ .../src/http/policy/create/v1/request.ts | 76 +++++ .../src/http/policy/create/v1/response.ts | 84 +++++ .../src/http/policy/get/v1/response.ts | 84 +++++ .../src/http/policy/list/v1/response.ts | 89 ++++++ .../src/http/policy/shared/v1/policy.ts | 81 +++++ .../src/http/policy/shared/v1/rule.ts | 68 ++++ .../src/http/policy/update/v1/request.ts | 82 +++++ .../src/http/policy/update/v1/response.ts | 84 +++++ packages/contracts/src/index.ts | 17 + .../deviceidentity/v1/certificate-identity.ts | 20 ++ packages/devboard/README.md | 4 +- .../devboard/src/manifest/cells.generated.ts | 122 +++++++- .../devboard/src/stores/useCellsStore.spec.ts | 4 +- tools/cell-manifest/README.md | 4 +- tools/cell-manifest/src/derive.spec.ts | 4 +- tools/cell-manifest/src/derive.ts | 4 +- tools/cell-manifest/src/index.ts | 6 +- 42 files changed, 1897 insertions(+), 41 deletions(-) create mode 100644 packages/contracts/src/http/admin/health/cells/v1/response.ts create mode 100644 packages/contracts/src/http/devicecompliance/v1/response.ts create mode 100644 packages/contracts/src/http/deviceidentity/enroll/v1/request.ts create mode 100644 packages/contracts/src/http/deviceidentity/enroll/v1/response.ts create mode 100644 packages/contracts/src/http/deviceidentity/renew/v1/request.ts create mode 100644 packages/contracts/src/http/deviceidentity/renew/v1/response.ts create mode 100644 packages/contracts/src/http/deviceidentity/revoke/v1/request.ts create mode 100644 packages/contracts/src/http/deviceidentity/revoke/v1/response.ts create mode 100644 packages/contracts/src/http/deviceidentity/status/v1/response.ts create mode 100644 packages/contracts/src/http/devicestate/v1/response.ts create mode 100644 packages/contracts/src/http/policy/create/v1/request.ts create mode 100644 packages/contracts/src/http/policy/create/v1/response.ts create mode 100644 packages/contracts/src/http/policy/get/v1/response.ts create mode 100644 packages/contracts/src/http/policy/list/v1/response.ts create mode 100644 packages/contracts/src/http/policy/shared/v1/policy.ts create mode 100644 packages/contracts/src/http/policy/shared/v1/rule.ts create mode 100644 packages/contracts/src/http/policy/update/v1/request.ts create mode 100644 packages/contracts/src/http/policy/update/v1/response.ts create mode 100644 packages/contracts/src/shared/deviceidentity/v1/certificate-identity.ts diff --git a/.github/workflows/cell-manifest-diff.yml b/.github/workflows/cell-manifest-diff.yml index d7f7e39..680aa5c 100644 --- a/.github/workflows/cell-manifest-diff.yml +++ b/.github/workflows/cell-manifest-diff.yml @@ -16,8 +16,7 @@ jobs: with: path: gocell-web - # 后端 cells 源(公开仓库 ghbvf/gocell),checkout 为 gocell-web 的同级目录 - # (cell-manifest 默认读 ../gocell/cells)。 + # 后端 corecells 源(公开仓库 ghbvf/gocell),checkout 为 gocell-web 的同级目录。 - name: Checkout gocell (backend cells) uses: actions/checkout@v4 with: @@ -43,6 +42,8 @@ jobs: - name: Regenerate cell manifest run: pnpm cell-manifest working-directory: gocell-web + env: + GOCELL_CELLS_DIR: ${{ github.workspace }}/gocell/corecells # 守门:cell manifest 产物须与提交完全一致。 # 使用 `git status --porcelain` 同时覆盖 修改/新增/删除 三种漂移。 diff --git a/apps/web/src/layouts/AppShellLayout.spec.ts b/apps/web/src/layouts/AppShellLayout.spec.ts index 1b0093c..d99aac5 100644 --- a/apps/web/src/layouts/AppShellLayout.spec.ts +++ b/apps/web/src/layouts/AppShellLayout.spec.ts @@ -50,6 +50,14 @@ describe('AppShellLayout — PDP deny notice', () => { 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') @@ -72,6 +80,23 @@ describe('AppShellLayout — PDP deny notice', () => { 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() diff --git a/apps/web/src/layouts/AppShellLayout.vue b/apps/web/src/layouts/AppShellLayout.vue index 1cedc9b..f91e045 100644 --- a/apps/web/src/layouts/AppShellLayout.vue +++ b/apps/web/src/layouts/AppShellLayout.vue @@ -39,8 +39,8 @@ function resetTimer(): void { } watch( - () => uiStore.accessDeniedReasonCode, - (reasonCode) => { + () => [uiStore.accessDeniedReasonCode, uiStore.accessDeniedNoticeSeq] as const, + ([reasonCode]) => { resetTimer() if (reasonCode === null) { denyText.value = '' @@ -52,6 +52,7 @@ watch( 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/stores/useUiStore.ts b/apps/web/src/stores/useUiStore.ts index e4bc1be..7720ccd 100644 --- a/apps/web/src/stores/useUiStore.ts +++ b/apps/web/src/stores/useUiStore.ts @@ -15,6 +15,7 @@ export const useUiStore = defineStore('web.ui', () => { 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 @@ -31,6 +32,7 @@ export const useUiStore = defineStore('web.ui', () => { /** 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). */ @@ -42,6 +44,7 @@ export const useUiStore = defineStore('web.ui', () => { commandPaletteOpen, sidebarCollapsed, accessDeniedReasonCode, + accessDeniedNoticeSeq, openCommandPalette, closeCommandPalette, toggleSidebar, diff --git a/packages/access/src/api/roles.spec.ts b/packages/access/src/api/roles.spec.ts index 0036522..b77bbf9 100644 --- a/packages/access/src/api/roles.spec.ts +++ b/packages/access/src/api/roles.spec.ts @@ -3,6 +3,8 @@ import MockAdapter from 'axios-mock-adapter' import { http } from '@gocell/request' import { listUserRoles, assignRole, revokeRole, ROLES_URL } from './roles' +const TENANT_ID = '00000000-0000-0000-0000-000000000001' + describe('roles api · listUserRoles', () => { let mock: MockAdapter @@ -51,7 +53,7 @@ describe('roles api · assignRole', () => { }) it('POSTs to /api/v1/access/roles/assign with the request body', async () => { - const body = { userId: 'u-1', roleId: 'role-1' } + const body = { tenantId: TENANT_ID, userId: 'u-1', roleId: 'role-1' } mock.onPost(`${ROLES_URL}/assign`).reply(200, { data: { userId: 'u-1', roleId: 'role-1', assigned: true }, }) @@ -63,7 +65,9 @@ describe('roles api · assignRole', () => { it('rejects and propagates when http rejects', async () => { mock.onPost(`${ROLES_URL}/assign`).networkError() - await expect(assignRole({ userId: 'u-1', roleId: 'role-1' })).rejects.toThrow() + await expect( + assignRole({ tenantId: TENANT_ID, userId: 'u-1', roleId: 'role-1' }), + ).rejects.toThrow() }) }) @@ -79,7 +83,7 @@ describe('roles api · revokeRole', () => { }) it('POSTs to /api/v1/access/roles/revoke with the request body', async () => { - const body = { userId: 'u-1', roleId: 'role-1' } + const body = { tenantId: TENANT_ID, userId: 'u-1', roleId: 'role-1' } mock.onPost(`${ROLES_URL}/revoke`).reply(200, { data: { userId: 'u-1', roleId: 'role-1', revoked: true }, }) @@ -91,6 +95,8 @@ describe('roles api · revokeRole', () => { it('rejects and propagates when http rejects', async () => { mock.onPost(`${ROLES_URL}/revoke`).networkError() - await expect(revokeRole({ userId: 'u-1', roleId: 'role-1' })).rejects.toThrow() + await expect( + revokeRole({ tenantId: TENANT_ID, userId: 'u-1', roleId: 'role-1' }), + ).rejects.toThrow() }) }) diff --git a/packages/access/src/pdp/createPdpClient.spec.ts b/packages/access/src/pdp/createPdpClient.spec.ts index 9ff378a..cca008e 100644 --- a/packages/access/src/pdp/createPdpClient.spec.ts +++ b/packages/access/src/pdp/createPdpClient.spec.ts @@ -113,14 +113,16 @@ describe('createPdpClient (mock-first PDP)', () => { const ref = client.can('read', 'cells') void ref.value // pending → fires decision - await vi.runAllTimersAsync() + await flushPromises() expect(decide).toHaveBeenCalledTimes(1) - // Advance past TTL without reading in between, so the computed stays dirty - // (invalidated by the resolve write) and re-runs its getter on next read. - vi.advanceTimersByTime(TTL_MS + 1) - void ref.value // re-run getter → expired → fresh decision - await vi.runAllTimersAsync() + expect(ref.value).toBe(true) + + // 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(decide).toHaveBeenCalledTimes(2) expect(ref.value).toBe(true) diff --git a/packages/access/src/pdp/createPdpClient.ts b/packages/access/src/pdp/createPdpClient.ts index bc43109..d921cfe 100644 --- a/packages/access/src/pdp/createPdpClient.ts +++ b/packages/access/src/pdp/createPdpClient.ts @@ -25,6 +25,7 @@ interface CacheEntry { interface Cache { entries: Record + expiryTick: number } function cacheKey(action: string, resource: string | undefined): string { @@ -52,9 +53,10 @@ export interface PdpClientOptions { */ export function createPdpClient(options: PdpClientOptions = {}): PdpClient { const decideFn: DecideFn = options.decide ?? createMockDecide() - const store = reactive({ entries: {} }) + 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 @@ -67,6 +69,18 @@ export function createPdpClient(options: PdpClientOptions = {}): PdpClient { 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) @@ -76,12 +90,16 @@ export function createPdpClient(options: PdpClientOptions = {}): PdpClient { const promise = (async (): Promise => { try { const decision = await decideFn({ action, resource }) - store.entries[key] = { decision, fetchedAt: Date.now() } + 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' } - store.entries[key] = { decision: denied, fetchedAt: Date.now() } + const fetchedAt = Date.now() + store.entries[key] = { decision: denied, fetchedAt } + scheduleExpiryTick(key, fetchedAt) return denied } finally { inFlight.delete(key) @@ -98,6 +116,7 @@ export function createPdpClient(options: PdpClientOptions = {}): PdpClient { if (cached) return cached const ref = computed(() => { + void store.expiryTick const fresh = freshDecision(action, resource) if (!fresh) { // 缺失 / 过期 → 触发异步取数(computed 必须同步,不 await);fail-closed 返回 false。 diff --git a/packages/access/src/stores/useAuthStore.spec.ts b/packages/access/src/stores/useAuthStore.spec.ts index 0815c2b..f33c5a5 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,6 +45,7 @@ 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) }) @@ -55,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) }) @@ -65,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) @@ -73,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 f9094f7..8582aec 100644 --- a/packages/access/src/stores/useAuthStore.ts +++ b/packages/access/src/stores/useAuthStore.ts @@ -24,10 +24,28 @@ 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) + 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 @@ -43,6 +61,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 @@ -52,6 +71,7 @@ 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 @@ -123,6 +143,7 @@ export const useAuthStore = defineStore('access.auth', () => { // state (expose as readonly refs via Pinia's reactive proxy) user, accessToken, + tenantId, passwordResetRequired, // getters isAuthenticated, diff --git a/packages/access/src/stores/usePoliciesStore.spec.ts b/packages/access/src/stores/usePoliciesStore.spec.ts index 1e54713..4930872 100644 --- a/packages/access/src/stores/usePoliciesStore.spec.ts +++ b/packages/access/src/stores/usePoliciesStore.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { usePoliciesStore } from './usePoliciesStore' +import { useAuthStore } from './useAuthStore' import type { Role } from '../api/roles' // Mirror the pattern from useIdentitiesStore.spec.ts — mock the api module, not @gocell/request @@ -14,6 +15,9 @@ vi.mock('../api/roles', () => ({ // Import mocked functions after vi.mock hoisting import { listUserRoles, assignRole, revokeRole } from '../api/roles' +const TENANT_ID = '00000000-0000-0000-0000-000000000001' +const ACCESS_TOKEN_WITH_TENANT = `e30.eyJ0ZW5hbnRfaWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtMDAwMC0wMDAwMDAwMDAwMDEifQ.sig` + const mkRole = (over: Partial = {}): Role => ({ id: 'role-1', name: 'admin', @@ -24,6 +28,14 @@ const mkRole = (over: Partial = {}): Role => ({ describe('usePoliciesStore', () => { beforeEach(() => { setActivePinia(createPinia()) + useAuthStore().setSession({ + accessToken: ACCESS_TOKEN_WITH_TENANT, + refreshToken: 'refresh-token', + expiresAt: '2026-06-01T00:00:00Z', + sessionId: 'sess-1', + userId: 'operator-1', + passwordResetRequired: false, + }) vi.resetAllMocks() }) @@ -155,11 +167,25 @@ describe('usePoliciesStore', () => { store.userId = 'u-1' await store.assign('role-1') - expect(assignRole).toHaveBeenCalledWith({ userId: 'u-1', roleId: 'role-1' }) + expect(assignRole).toHaveBeenCalledWith({ + tenantId: TENANT_ID, + userId: 'u-1', + roleId: 'role-1', + }) expect(listUserRoles).toHaveBeenCalledWith('u-1') expect(store.roles).toHaveLength(1) }) + it('fails before mutation when the session has no tenant context', async () => { + useAuthStore().clearSession() + const store = usePoliciesStore() + store.userId = 'u-1' + + await expect(store.assign('role-1')).rejects.toThrow('Tenant context is required') + expect(assignRole).not.toHaveBeenCalled() + expect(store.mutating).toBe(false) + }) + it('re-throws on failure and does NOT set errorKey', async () => { vi.mocked(assignRole).mockRejectedValueOnce(new Error('assign failed')) @@ -226,7 +252,11 @@ describe('usePoliciesStore', () => { // assignRole should have been called exactly once expect(assignRole).toHaveBeenCalledTimes(1) - expect(assignRole).toHaveBeenCalledWith({ userId: 'u-1', roleId: 'role-1' }) + expect(assignRole).toHaveBeenCalledWith({ + tenantId: TENANT_ID, + userId: 'u-1', + roleId: 'role-1', + }) }) }) @@ -239,7 +269,11 @@ describe('usePoliciesStore', () => { store.userId = 'u-1' await store.revoke('role-1') - expect(revokeRole).toHaveBeenCalledWith({ userId: 'u-1', roleId: 'role-1' }) + expect(revokeRole).toHaveBeenCalledWith({ + tenantId: TENANT_ID, + userId: 'u-1', + roleId: 'role-1', + }) expect(listUserRoles).toHaveBeenCalledWith('u-1') }) @@ -288,7 +322,11 @@ describe('usePoliciesStore', () => { await p1 expect(revokeRole).toHaveBeenCalledTimes(1) - expect(revokeRole).toHaveBeenCalledWith({ userId: 'u-1', roleId: 'role-1' }) + expect(revokeRole).toHaveBeenCalledWith({ + tenantId: TENANT_ID, + userId: 'u-1', + roleId: 'role-1', + }) }) }) }) diff --git a/packages/access/src/stores/usePoliciesStore.ts b/packages/access/src/stores/usePoliciesStore.ts index 002c9e0..b9e244f 100644 --- a/packages/access/src/stores/usePoliciesStore.ts +++ b/packages/access/src/stores/usePoliciesStore.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { toI18nKey } from '@gocell/request' import { listUserRoles, assignRole, revokeRole, type Role } from '../api/roles' +import { useAuthStore } from './useAuthStore' /** * access.policies — per-user role-binding state (Batch 3 RBAC slice). @@ -26,6 +27,14 @@ export const usePoliciesStore = defineStore('access.policies', () => { const errorKey = ref(null) const mutating = ref(false) + function requireTenantId(): string { + const tenantId = useAuthStore().tenantId + if (tenantId === null) { + throw new Error('Tenant context is required for role mutations') + } + return tenantId + } + // ─── read actions ─────────────────────────────────────────────────────── /** @@ -64,7 +73,7 @@ export const usePoliciesStore = defineStore('access.policies', () => { if (mutating.value) return mutating.value = true try { - await assignRole({ userId: userId.value, roleId }) + await assignRole({ tenantId: requireTenantId(), userId: userId.value, roleId }) await fetchRoles(userId.value) } finally { mutating.value = false @@ -80,7 +89,7 @@ export const usePoliciesStore = defineStore('access.policies', () => { if (mutating.value) return mutating.value = true try { - await revokeRole({ userId: userId.value, roleId }) + await revokeRole({ tenantId: requireTenantId(), userId: userId.value, roleId }) await fetchRoles(userId.value) } finally { mutating.value = false diff --git a/packages/contracts/src/http/admin/health/cells/v1/response.ts b/packages/contracts/src/http/admin/health/cells/v1/response.ts new file mode 100644 index 0000000..d75c3d3 --- /dev/null +++ b/packages/contracts/src/http/admin/health/cells/v1/response.ts @@ -0,0 +1,61 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/admin/health/cells/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +/** + * Aggregated runtime health for operator/admin consoles. A single composite resource wrapped in the {data: {...}} envelope (NOT a paginated list): the cell set is bounded by the assembly. `cells` carries per-cell live/ready/status plus the cell's own readiness probes (deps); `adapters` carries the assembly-global infrastructure probes (postgres_ready/redis_ready/... and framework probes) that are not owned by any single cell. + */ +export interface HttpAdminHealthCellsV1Response { + data: { + /** + * Worst-case status across every cell and adapter probe. One of: healthy | degraded | unhealthy. + */ + overall: string; + /** + * Per-cell health, in assembly registration order. + */ + cells: { + /** + * Cell identifier (assembly closed set). + */ + id: string; + /** + * Cell is running in the started assembly. In-process liveness equals assembly-started, so this is true while the endpoint is reachable; the discriminating signals are ready and status. + */ + live: boolean; + /** + * Cell reports itself ready to serve (CellStatus.Ready). + */ + ready: boolean; + /** + * Cell health snapshot. One of: healthy | degraded | unhealthy. + */ + status: string; + /** + * Readiness probes this cell registered (structurally per-cell). + */ + deps: { + name: string; + /** + * Probe status. One of: healthy | degraded | unhealthy | timeout. + */ + status: string; + durationMs: number; + }[]; + }[]; + /** + * Assembly-global infrastructure probes not owned by any cell (adapter *_ready probes plus framework probes), by structural set-difference against the per-cell probe sets. + */ + adapters: { + name: string; + /** + * Probe status. One of: healthy | degraded | unhealthy | timeout. + */ + status: string; + durationMs: number; + }[]; + }; +} diff --git a/packages/contracts/src/http/audit/list/v1/response.ts b/packages/contracts/src/http/audit/list/v1/response.ts index 3219cd5..1e78e22 100644 --- a/packages/contracts/src/http/audit/list/v1/response.ts +++ b/packages/contracts/src/http/audit/list/v1/response.ts @@ -13,15 +13,23 @@ export interface HttpAuditListV1Response { actorId: string; subjectId?: string; /** - * Opaque cross-cell correlation identifier from the originating request context. Lets consumers stitch an audited action back to its request. Empty for entries predating its introduction. + * Tenant boundary id the audit row belongs to (epic #1337 PR-12). Surfaced behind the ResourceProjection column-masking funnel: an admin sees the value, lower-privilege/device callers may see it masked per the FieldMask obligation. Empty for tenant-less system rows (scope="system") and for entries predating its introduction. + */ + tenantId?: string; + /** + * Operator-diagnostic column: opaque cross-cell correlation identifier from the originating request context. Visible to admin/tenant-scope callers; value-masked ("") for non-admin (self) and device callers per the FieldMask obligation (epic #1337 PR-12). Empty for entries predating its introduction. */ correlationId?: string; /** - * Opaque distributed trace identifier from the originating request context. Lets consumers correlate an audited action back to its trace. Empty for entries predating its introduction. + * Operator-diagnostic column: opaque distributed trace identifier from the originating request context. Visible to admin/tenant-scope callers; value-masked ("") for non-admin (self) and device callers per the FieldMask obligation (epic #1337 PR-12). Empty for entries predating its introduction. */ traceId?: string; occurredAt?: string; timestamp: string; + /** + * Row classification: "tenant" means the row belongs to a tenant (the owning tenant id is in the tenantId field; for a super-admin cross-tenant read rows from multiple tenants may appear, each with their own tenantId); "system" for tenant-less framework/system rows (e.g. bootstrap.auth.fail) that are surfaced to every tenant. Empty for rows predating its introduction. + */ + scope?: string; payload?: unknown; }[]; nextCursor: string; diff --git a/packages/contracts/src/http/auth/refresh/v1/request.ts b/packages/contracts/src/http/auth/refresh/v1/request.ts index 853743f..b4a106f 100644 --- a/packages/contracts/src/http/auth/refresh/v1/request.ts +++ b/packages/contracts/src/http/auth/refresh/v1/request.ts @@ -6,5 +6,5 @@ */ export interface HttpAuthRefreshV1Request { - refreshToken: string; + refreshToken?: string; } diff --git a/packages/contracts/src/http/auth/role/assign/v1/request.ts b/packages/contracts/src/http/auth/role/assign/v1/request.ts index a64fd50..44449a8 100644 --- a/packages/contracts/src/http/auth/role/assign/v1/request.ts +++ b/packages/contracts/src/http/auth/role/assign/v1/request.ts @@ -6,6 +6,7 @@ */ export interface HttpAuthRoleAssignV1Request { + tenantId: string; userId: string; roleId: string; } diff --git a/packages/contracts/src/http/auth/role/revoke/v1/request.ts b/packages/contracts/src/http/auth/role/revoke/v1/request.ts index d2ef7c7..0d3422a 100644 --- a/packages/contracts/src/http/auth/role/revoke/v1/request.ts +++ b/packages/contracts/src/http/auth/role/revoke/v1/request.ts @@ -6,6 +6,7 @@ */ export interface HttpAuthRoleRevokeV1Request { + tenantId: string; userId: string; roleId: string; } diff --git a/packages/contracts/src/http/devicecompliance/v1/response.ts b/packages/contracts/src/http/devicecompliance/v1/response.ts new file mode 100644 index 0000000..6a9e734 --- /dev/null +++ b/packages/contracts/src/http/devicecompliance/v1/response.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/devicecompliance/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDevicecomplianceV1Response { + data: { + /** + * Provider-neutral device identifier, echoed from the query parameter. + */ + deviceId: string; + /** + * Overall posture verdict: true when the device satisfies the policy baseline. Provider-agnostic roll-up of the individual posture attributes (and any provider-specific signals not surfaced as discrete fields). + */ + compliant: boolean; + /** + * Disk encryption posture, provider-neutral (BitLocker / FileVault / LUKS). unknown when the provider did not report it. + */ + diskEncryption: "enabled" | "disabled" | "unknown"; + /** + * Antivirus / endpoint-protection posture. unknown when the provider did not report it. + */ + antivirus: "enabled" | "disabled" | "unknown"; + /** + * OS / security patch posture. unknown when the provider did not report it. + */ + patch: "upToDate" | "outOfDate" | "unknown"; + /** + * Host firewall posture. unknown when the provider did not report it. + */ + firewall: "enabled" | "disabled" | "unknown"; + /** + * Time this posture reading was determined (freshness anchor); always present — a posture verdict without a timestamp is unsafe to trust for Authorize decisions. + */ + observedAt: string; + /** + * Tenant this posture reading belongs to; framework-derived from the authenticated principal, never accepted as a client query parameter. + */ + tenantId?: string; + }; +} diff --git a/packages/contracts/src/http/deviceidentity/enroll/v1/request.ts b/packages/contracts/src/http/deviceidentity/enroll/v1/request.ts new file mode 100644 index 0000000..1858952 --- /dev/null +++ b/packages/contracts/src/http/deviceidentity/enroll/v1/request.ts @@ -0,0 +1,292 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/deviceidentity/enroll/v1/request.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDeviceidentityEnrollV1Request { + /** + * Base64-encoded PKCS#10 DER certificate signing request. + */ + csr: string; + deviceId: string; + /** + * Requested cert lifetime as a Go duration string, e.g. "2160h". Clamped to the Signer's max-TTL SignConstraints (PR-5). + */ + requestedDuration?: string; + /** + * Requested X.509 key/extended-key usages (cert-manager vocabulary: signing, digital signature, content commitment, key encipherment, key agreement, data encipherment, cert sign, crl sign, encipher only, decipher only, any, server auth, client auth, code signing, email protection, s/mime, ipsec end system, ipsec tunnel, ipsec user, timestamping, ocsp signing, microsoft sgc, netscape sgc). The allowed-usage set is enforced by the Signer SignConstraints at issuance (spec FR-005, PR-5), not at the wire layer. ref: cert-manager KeyUsage; RFC 5280 §4.2.1.3. + * + * @maxItems 16 + */ + usages?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + subjectAltNames?: { + /** + * @maxItems 16 + */ + dnsNames?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + /** + * @maxItems 16 + */ + ipAddresses?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + /** + * @maxItems 16 + */ + uris?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + /** + * @maxItems 16 + */ + emailAddresses?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + }; +} diff --git a/packages/contracts/src/http/deviceidentity/enroll/v1/response.ts b/packages/contracts/src/http/deviceidentity/enroll/v1/response.ts new file mode 100644 index 0000000..12414f2 --- /dev/null +++ b/packages/contracts/src/http/deviceidentity/enroll/v1/response.ts @@ -0,0 +1,44 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/deviceidentity/enroll/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDeviceidentityEnrollV1Response { + data: { + /** + * Issued leaf certificate, base64-encoded DER (X.509). + */ + certificate: string; + /** + * PKCS#7 certs-only chain (leaf→root), base64-encoded DER. + */ + chain: string; + certRef: DeviceidentityCertificateIdentity; + notBefore: string; + notAfter: string; + /** + * Monotonic issuance epoch; increments on each rotation. + */ + epoch: number; + /** + * Certificate lifecycle state. + */ + status: "requested" | "issued" | "active" | "near-expiry" | "renewing" | "rotated" | "revoked" | "expired"; + deviceId?: string; + }; +} +/** + * Provider-neutral X.509 certificate identity: an issuing CA identifier plus a serial number uniquely identify a certificate (RFC 5280 — issuer name + serial, not a globally-unique serial). Single source shared by deviceidentity status/revoke contracts and the cert-issued/cert-revoked events so every endpoint references the same identity shape. ref: RFC 5280 §4.1.2.2. + */ +export interface DeviceidentityCertificateIdentity { + /** + * Issuing CA identifier; with serial forms the X.509 unique identity (RFC 5280 issuer + serial). + */ + issuer: string; + /** + * Certificate serial number, hex (RFC 5280 §4.1.2.2). + */ + serial: string; +} diff --git a/packages/contracts/src/http/deviceidentity/renew/v1/request.ts b/packages/contracts/src/http/deviceidentity/renew/v1/request.ts new file mode 100644 index 0000000..48fecd1 --- /dev/null +++ b/packages/contracts/src/http/deviceidentity/renew/v1/request.ts @@ -0,0 +1,293 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/deviceidentity/renew/v1/request.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDeviceidentityRenewV1Request { + /** + * Base64-encoded PKCS#10 DER certificate signing request. + */ + csr: string; + deviceId: string; + priorSerial: string; + /** + * Requested cert lifetime as a Go duration string, e.g. "2160h". Clamped to the Signer's max-TTL SignConstraints (PR-5). + */ + requestedDuration?: string; + /** + * Requested X.509 key/extended-key usages (cert-manager vocabulary: signing, digital signature, content commitment, key encipherment, key agreement, data encipherment, cert sign, crl sign, encipher only, decipher only, any, server auth, client auth, code signing, email protection, s/mime, ipsec end system, ipsec tunnel, ipsec user, timestamping, ocsp signing, microsoft sgc, netscape sgc). The allowed-usage set is enforced by the Signer SignConstraints at issuance (spec FR-005, PR-5), not at the wire layer. ref: cert-manager KeyUsage; RFC 5280 §4.2.1.3. + * + * @maxItems 16 + */ + usages?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + subjectAltNames?: { + /** + * @maxItems 16 + */ + dnsNames?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + /** + * @maxItems 16 + */ + ipAddresses?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + /** + * @maxItems 16 + */ + uris?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + /** + * @maxItems 16 + */ + emailAddresses?: + | [] + | [string] + | [string, string] + | [string, string, string] + | [string, string, string, string] + | [string, string, string, string, string] + | [string, string, string, string, string, string] + | [string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string] + | [string, string, string, string, string, string, string, string, string, string, string, string, string, string] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ] + | [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string + ]; + }; +} diff --git a/packages/contracts/src/http/deviceidentity/renew/v1/response.ts b/packages/contracts/src/http/deviceidentity/renew/v1/response.ts new file mode 100644 index 0000000..87612ec --- /dev/null +++ b/packages/contracts/src/http/deviceidentity/renew/v1/response.ts @@ -0,0 +1,48 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/deviceidentity/renew/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDeviceidentityRenewV1Response { + data: { + /** + * Issued leaf certificate, base64-encoded DER (X.509). + */ + certificate: string; + /** + * PKCS#7 certs-only chain (leaf→root), base64-encoded DER. + */ + chain: string; + certRef: DeviceidentityCertificateIdentity; + notBefore: string; + notAfter: string; + /** + * Monotonic issuance epoch; increments on each rotation. + */ + epoch: number; + /** + * Certificate lifecycle state. + */ + status: "requested" | "issued" | "active" | "near-expiry" | "renewing" | "rotated" | "revoked" | "expired"; + deviceId?: string; + /** + * Serial of the rotated-out prior certificate (hex, same issuer as certRef; RFC 5280 §4.1.2.2). + */ + priorSerial?: string; + }; +} +/** + * Provider-neutral X.509 certificate identity: an issuing CA identifier plus a serial number uniquely identify a certificate (RFC 5280 — issuer name + serial, not a globally-unique serial). Single source shared by deviceidentity status/revoke contracts and the cert-issued/cert-revoked events so every endpoint references the same identity shape. ref: RFC 5280 §4.1.2.2. + */ +export interface DeviceidentityCertificateIdentity { + /** + * Issuing CA identifier; with serial forms the X.509 unique identity (RFC 5280 issuer + serial). + */ + issuer: string; + /** + * Certificate serial number, hex (RFC 5280 §4.1.2.2). + */ + serial: string; +} diff --git a/packages/contracts/src/http/deviceidentity/revoke/v1/request.ts b/packages/contracts/src/http/deviceidentity/revoke/v1/request.ts new file mode 100644 index 0000000..c89f93e --- /dev/null +++ b/packages/contracts/src/http/deviceidentity/revoke/v1/request.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/deviceidentity/revoke/v1/request.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDeviceidentityRevokeV1Request { + certRef: DeviceidentityCertificateIdentity; + deviceId: string; + /** + * RFC 5280 §5.3.1 CRLReason for permanent revocation. removeFromCRL (un-revoke) and the reinstatement path are intentionally excluded — this is a revoke action, not a hold/unhold lifecycle event. + */ + reason: + | "unspecified" + | "keyCompromise" + | "caCompromise" + | "affiliationChanged" + | "superseded" + | "cessationOfOperation" + | "certificateHold" + | "privilegeWithdrawn" + | "aaCompromise"; +} +/** + * Provider-neutral X.509 certificate identity: an issuing CA identifier plus a serial number uniquely identify a certificate (RFC 5280 — issuer name + serial, not a globally-unique serial). Single source shared by deviceidentity status/revoke contracts and the cert-issued/cert-revoked events so every endpoint references the same identity shape. ref: RFC 5280 §4.1.2.2. + */ +export interface DeviceidentityCertificateIdentity { + /** + * Issuing CA identifier; with serial forms the X.509 unique identity (RFC 5280 issuer + serial). + */ + issuer: string; + /** + * Certificate serial number, hex (RFC 5280 §4.1.2.2). + */ + serial: string; +} diff --git a/packages/contracts/src/http/deviceidentity/revoke/v1/response.ts b/packages/contracts/src/http/deviceidentity/revoke/v1/response.ts new file mode 100644 index 0000000..2e674e2 --- /dev/null +++ b/packages/contracts/src/http/deviceidentity/revoke/v1/response.ts @@ -0,0 +1,40 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/deviceidentity/revoke/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDeviceidentityRevokeV1Response { + data: { + certRef: DeviceidentityCertificateIdentity; + status: "requested" | "issued" | "active" | "near-expiry" | "renewing" | "rotated" | "revoked" | "expired"; + /** + * RFC 5280 §5.3.1 CRLReason for permanent revocation. removeFromCRL (un-revoke) and the reinstatement path are intentionally excluded — this is a revoke action, not a hold/unhold lifecycle event. + */ + reason?: + | "unspecified" + | "keyCompromise" + | "caCompromise" + | "affiliationChanged" + | "superseded" + | "cessationOfOperation" + | "certificateHold" + | "privilegeWithdrawn" + | "aaCompromise"; + revokedAt: string; + }; +} +/** + * Provider-neutral X.509 certificate identity: an issuing CA identifier plus a serial number uniquely identify a certificate (RFC 5280 — issuer name + serial, not a globally-unique serial). Single source shared by deviceidentity status/revoke contracts and the cert-issued/cert-revoked events so every endpoint references the same identity shape. ref: RFC 5280 §4.1.2.2. + */ +export interface DeviceidentityCertificateIdentity { + /** + * Issuing CA identifier; with serial forms the X.509 unique identity (RFC 5280 issuer + serial). + */ + issuer: string; + /** + * Certificate serial number, hex (RFC 5280 §4.1.2.2). + */ + serial: string; +} diff --git a/packages/contracts/src/http/deviceidentity/status/v1/response.ts b/packages/contracts/src/http/deviceidentity/status/v1/response.ts new file mode 100644 index 0000000..ad12b8b --- /dev/null +++ b/packages/contracts/src/http/deviceidentity/status/v1/response.ts @@ -0,0 +1,31 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/deviceidentity/status/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDeviceidentityStatusV1Response { + data: { + deviceId: string; + certRef: DeviceidentityCertificateIdentity; + status: "requested" | "issued" | "active" | "near-expiry" | "renewing" | "rotated" | "revoked" | "expired"; + notBefore: string; + notAfter: string; + epoch: number; + renewalTime?: string; + }; +} +/** + * Provider-neutral X.509 certificate identity: an issuing CA identifier plus a serial number uniquely identify a certificate (RFC 5280 — issuer name + serial, not a globally-unique serial). Single source shared by deviceidentity status/revoke contracts and the cert-issued/cert-revoked events so every endpoint references the same identity shape. ref: RFC 5280 §4.1.2.2. + */ +export interface DeviceidentityCertificateIdentity { + /** + * Issuing CA identifier; with serial forms the X.509 unique identity (RFC 5280 issuer + serial). + */ + issuer: string; + /** + * Certificate serial number, hex (RFC 5280 §4.1.2.2). + */ + serial: string; +} diff --git a/packages/contracts/src/http/devicestate/v1/response.ts b/packages/contracts/src/http/devicestate/v1/response.ts new file mode 100644 index 0000000..a9db395 --- /dev/null +++ b/packages/contracts/src/http/devicestate/v1/response.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/devicestate/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpDevicestateV1Response { + data: { + deviceId: string; + state: "online" | "offline" | "unknown"; + /** + * Time this presence reading was determined (freshness anchor); always present even when state is unknown. + */ + observedAt: string; + lastSeenAt?: string; + tenantId?: string; + }; +} diff --git a/packages/contracts/src/http/policy/create/v1/request.ts b/packages/contracts/src/http/policy/create/v1/request.ts new file mode 100644 index 0000000..2960903 --- /dev/null +++ b/packages/contracts/src/http/policy/create/v1/request.ts @@ -0,0 +1,76 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/policy/create/v1/request.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpPolicyCreateV1Request { + name: string; + description?: string; + /** + * @minItems 1 + */ + rules: [PolicyRule, ...PolicyRule[]]; +} +/** + * A single ABAC rule within a Policy. Referenced by policy create/update request schemas and policy get/list/create/update response schemas. + */ +export interface PolicyRule { + /** + * Stable client-assigned rule identifier (scoped to the policy). + */ + id: string; + /** + * Human-readable rule label. + */ + name: string; + /** + * Authorization verdict. Valid values: allow, deny. Deny-overrides: any deny wins over all allows. + */ + effect: string; + /** + * All conditions are AND-combined. Empty or absent means unconditional (rule always applies). + */ + conditions?: { + /** + * Attribute namespace. Valid values: subject (principal claims), resource (protected object attrs), environment (context). + */ + source: string; + /** + * Attribute name within the source namespace (e.g. department, classification, time_of_day). + */ + key: string; + /** + * Comparison applied between the resolved attribute and the right-hand side. Valid values: eq, neq, in, not_in (static: compare against values), eq_attr (cross-attribute: compare against rhsSource/rhsKey instead of values). + */ + operator: string; + /** + * Right-hand side of the comparison for static operators (eq, neq, in, not_in). Must be non-empty when present. Absent for cross-attribute operator eq_attr. + * + * @minItems 1 + */ + values?: [string, ...string[]]; + /** + * Cross-attribute right-hand-side namespace. Set only for operator eq_attr. Valid values: subject, resource, environment. + */ + rhsSource?: string; + /** + * Cross-attribute right-hand-side attribute name. Set only for operator eq_attr. + */ + rhsKey?: string; + }[]; + /** + * Obligations imposed on the PEP when this rule matches. All fields are optional. + */ + obligations?: { + /** + * Row-visibility obligation. Grantable values: self, device, tenant. The value 'all' (cross-tenant) is reserved for the audited super-admin derivation and is rejected with 422 when supplied via policy authoring. Absent means no row-scope constraint. + */ + rowScope?: string; + /** + * Fields the PEP must redact from the response. Absent or empty means no field-mask obligation. + */ + fieldMask?: string[]; + }; +} diff --git a/packages/contracts/src/http/policy/create/v1/response.ts b/packages/contracts/src/http/policy/create/v1/response.ts new file mode 100644 index 0000000..201c22a --- /dev/null +++ b/packages/contracts/src/http/policy/create/v1/response.ts @@ -0,0 +1,84 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/policy/create/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpPolicyCreateV1Response { + data: Policy; +} +/** + * ABAC Policy response object. Deliberately carries no createdAt/updatedAt — the Policy domain uses CAS version for conflict detection, not timestamps. + */ +export interface Policy { + id: string; + name: string; + description?: string; + /** + * Monotonically increasing CAS version. + */ + version: number; + rules: PolicyRule[]; +} +/** + * A single ABAC rule within a Policy. Referenced by policy create/update request schemas and policy get/list/create/update response schemas. + */ +export interface PolicyRule { + /** + * Stable client-assigned rule identifier (scoped to the policy). + */ + id: string; + /** + * Human-readable rule label. + */ + name: string; + /** + * Authorization verdict. Valid values: allow, deny. Deny-overrides: any deny wins over all allows. + */ + effect: string; + /** + * All conditions are AND-combined. Empty or absent means unconditional (rule always applies). + */ + conditions?: { + /** + * Attribute namespace. Valid values: subject (principal claims), resource (protected object attrs), environment (context). + */ + source: string; + /** + * Attribute name within the source namespace (e.g. department, classification, time_of_day). + */ + key: string; + /** + * Comparison applied between the resolved attribute and the right-hand side. Valid values: eq, neq, in, not_in (static: compare against values), eq_attr (cross-attribute: compare against rhsSource/rhsKey instead of values). + */ + operator: string; + /** + * Right-hand side of the comparison for static operators (eq, neq, in, not_in). Must be non-empty when present. Absent for cross-attribute operator eq_attr. + * + * @minItems 1 + */ + values?: [string, ...string[]]; + /** + * Cross-attribute right-hand-side namespace. Set only for operator eq_attr. Valid values: subject, resource, environment. + */ + rhsSource?: string; + /** + * Cross-attribute right-hand-side attribute name. Set only for operator eq_attr. + */ + rhsKey?: string; + }[]; + /** + * Obligations imposed on the PEP when this rule matches. All fields are optional. + */ + obligations?: { + /** + * Row-visibility obligation. Grantable values: self, device, tenant. The value 'all' (cross-tenant) is reserved for the audited super-admin derivation and is rejected with 422 when supplied via policy authoring. Absent means no row-scope constraint. + */ + rowScope?: string; + /** + * Fields the PEP must redact from the response. Absent or empty means no field-mask obligation. + */ + fieldMask?: string[]; + }; +} diff --git a/packages/contracts/src/http/policy/get/v1/response.ts b/packages/contracts/src/http/policy/get/v1/response.ts new file mode 100644 index 0000000..95617db --- /dev/null +++ b/packages/contracts/src/http/policy/get/v1/response.ts @@ -0,0 +1,84 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/policy/get/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpPolicyGetV1Response { + data: Policy; +} +/** + * ABAC Policy response object. Deliberately carries no createdAt/updatedAt — the Policy domain uses CAS version for conflict detection, not timestamps. + */ +export interface Policy { + id: string; + name: string; + description?: string; + /** + * Monotonically increasing CAS version. + */ + version: number; + rules: PolicyRule[]; +} +/** + * A single ABAC rule within a Policy. Referenced by policy create/update request schemas and policy get/list/create/update response schemas. + */ +export interface PolicyRule { + /** + * Stable client-assigned rule identifier (scoped to the policy). + */ + id: string; + /** + * Human-readable rule label. + */ + name: string; + /** + * Authorization verdict. Valid values: allow, deny. Deny-overrides: any deny wins over all allows. + */ + effect: string; + /** + * All conditions are AND-combined. Empty or absent means unconditional (rule always applies). + */ + conditions?: { + /** + * Attribute namespace. Valid values: subject (principal claims), resource (protected object attrs), environment (context). + */ + source: string; + /** + * Attribute name within the source namespace (e.g. department, classification, time_of_day). + */ + key: string; + /** + * Comparison applied between the resolved attribute and the right-hand side. Valid values: eq, neq, in, not_in (static: compare against values), eq_attr (cross-attribute: compare against rhsSource/rhsKey instead of values). + */ + operator: string; + /** + * Right-hand side of the comparison for static operators (eq, neq, in, not_in). Must be non-empty when present. Absent for cross-attribute operator eq_attr. + * + * @minItems 1 + */ + values?: [string, ...string[]]; + /** + * Cross-attribute right-hand-side namespace. Set only for operator eq_attr. Valid values: subject, resource, environment. + */ + rhsSource?: string; + /** + * Cross-attribute right-hand-side attribute name. Set only for operator eq_attr. + */ + rhsKey?: string; + }[]; + /** + * Obligations imposed on the PEP when this rule matches. All fields are optional. + */ + obligations?: { + /** + * Row-visibility obligation. Grantable values: self, device, tenant. The value 'all' (cross-tenant) is reserved for the audited super-admin derivation and is rejected with 422 when supplied via policy authoring. Absent means no row-scope constraint. + */ + rowScope?: string; + /** + * Fields the PEP must redact from the response. Absent or empty means no field-mask obligation. + */ + fieldMask?: string[]; + }; +} diff --git a/packages/contracts/src/http/policy/list/v1/response.ts b/packages/contracts/src/http/policy/list/v1/response.ts new file mode 100644 index 0000000..d2b7a34 --- /dev/null +++ b/packages/contracts/src/http/policy/list/v1/response.ts @@ -0,0 +1,89 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/policy/list/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +/** + * Paginated list of ABAC Policies. + */ +export interface HttpPolicyListV1Response { + data: Policy[]; + nextCursor: string; + hasMore: boolean; +} +/** + * ABAC Policy response object. Deliberately carries no createdAt/updatedAt — the Policy domain uses CAS version for conflict detection, not timestamps. + */ +export interface Policy { + id: string; + name: string; + description?: string; + /** + * Monotonically increasing CAS version. + */ + version: number; + rules: PolicyRule[]; +} +/** + * A single ABAC rule within a Policy. Referenced by policy create/update request schemas and policy get/list/create/update response schemas. + */ +export interface PolicyRule { + /** + * Stable client-assigned rule identifier (scoped to the policy). + */ + id: string; + /** + * Human-readable rule label. + */ + name: string; + /** + * Authorization verdict. Valid values: allow, deny. Deny-overrides: any deny wins over all allows. + */ + effect: string; + /** + * All conditions are AND-combined. Empty or absent means unconditional (rule always applies). + */ + conditions?: { + /** + * Attribute namespace. Valid values: subject (principal claims), resource (protected object attrs), environment (context). + */ + source: string; + /** + * Attribute name within the source namespace (e.g. department, classification, time_of_day). + */ + key: string; + /** + * Comparison applied between the resolved attribute and the right-hand side. Valid values: eq, neq, in, not_in (static: compare against values), eq_attr (cross-attribute: compare against rhsSource/rhsKey instead of values). + */ + operator: string; + /** + * Right-hand side of the comparison for static operators (eq, neq, in, not_in). Must be non-empty when present. Absent for cross-attribute operator eq_attr. + * + * @minItems 1 + */ + values?: [string, ...string[]]; + /** + * Cross-attribute right-hand-side namespace. Set only for operator eq_attr. Valid values: subject, resource, environment. + */ + rhsSource?: string; + /** + * Cross-attribute right-hand-side attribute name. Set only for operator eq_attr. + */ + rhsKey?: string; + }[]; + /** + * Obligations imposed on the PEP when this rule matches. All fields are optional. + */ + obligations?: { + /** + * Row-visibility obligation. Grantable values: self, device, tenant. The value 'all' (cross-tenant) is reserved for the audited super-admin derivation and is rejected with 422 when supplied via policy authoring. Absent means no row-scope constraint. + */ + rowScope?: string; + /** + * Fields the PEP must redact from the response. Absent or empty means no field-mask obligation. + */ + fieldMask?: string[]; + }; +} diff --git a/packages/contracts/src/http/policy/shared/v1/policy.ts b/packages/contracts/src/http/policy/shared/v1/policy.ts new file mode 100644 index 0000000..d1d4a6e --- /dev/null +++ b/packages/contracts/src/http/policy/shared/v1/policy.ts @@ -0,0 +1,81 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/policy/shared/v1/policy.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +/** + * ABAC Policy response object. Deliberately carries no createdAt/updatedAt — the Policy domain uses CAS version for conflict detection, not timestamps. + */ +export interface Policy { + id: string; + name: string; + description?: string; + /** + * Monotonically increasing CAS version. + */ + version: number; + rules: PolicyRule[]; +} +/** + * A single ABAC rule within a Policy. Referenced by policy create/update request schemas and policy get/list/create/update response schemas. + */ +export interface PolicyRule { + /** + * Stable client-assigned rule identifier (scoped to the policy). + */ + id: string; + /** + * Human-readable rule label. + */ + name: string; + /** + * Authorization verdict. Valid values: allow, deny. Deny-overrides: any deny wins over all allows. + */ + effect: string; + /** + * All conditions are AND-combined. Empty or absent means unconditional (rule always applies). + */ + conditions?: { + /** + * Attribute namespace. Valid values: subject (principal claims), resource (protected object attrs), environment (context). + */ + source: string; + /** + * Attribute name within the source namespace (e.g. department, classification, time_of_day). + */ + key: string; + /** + * Comparison applied between the resolved attribute and the right-hand side. Valid values: eq, neq, in, not_in (static: compare against values), eq_attr (cross-attribute: compare against rhsSource/rhsKey instead of values). + */ + operator: string; + /** + * Right-hand side of the comparison for static operators (eq, neq, in, not_in). Must be non-empty when present. Absent for cross-attribute operator eq_attr. + * + * @minItems 1 + */ + values?: [string, ...string[]]; + /** + * Cross-attribute right-hand-side namespace. Set only for operator eq_attr. Valid values: subject, resource, environment. + */ + rhsSource?: string; + /** + * Cross-attribute right-hand-side attribute name. Set only for operator eq_attr. + */ + rhsKey?: string; + }[]; + /** + * Obligations imposed on the PEP when this rule matches. All fields are optional. + */ + obligations?: { + /** + * Row-visibility obligation. Grantable values: self, device, tenant. The value 'all' (cross-tenant) is reserved for the audited super-admin derivation and is rejected with 422 when supplied via policy authoring. Absent means no row-scope constraint. + */ + rowScope?: string; + /** + * Fields the PEP must redact from the response. Absent or empty means no field-mask obligation. + */ + fieldMask?: string[]; + }; +} diff --git a/packages/contracts/src/http/policy/shared/v1/rule.ts b/packages/contracts/src/http/policy/shared/v1/rule.ts new file mode 100644 index 0000000..587bd02 --- /dev/null +++ b/packages/contracts/src/http/policy/shared/v1/rule.ts @@ -0,0 +1,68 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/policy/shared/v1/rule.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +/** + * A single ABAC rule within a Policy. Referenced by policy create/update request schemas and policy get/list/create/update response schemas. + */ +export interface PolicyRule { + /** + * Stable client-assigned rule identifier (scoped to the policy). + */ + id: string; + /** + * Human-readable rule label. + */ + name: string; + /** + * Authorization verdict. Valid values: allow, deny. Deny-overrides: any deny wins over all allows. + */ + effect: string; + /** + * All conditions are AND-combined. Empty or absent means unconditional (rule always applies). + */ + conditions?: { + /** + * Attribute namespace. Valid values: subject (principal claims), resource (protected object attrs), environment (context). + */ + source: string; + /** + * Attribute name within the source namespace (e.g. department, classification, time_of_day). + */ + key: string; + /** + * Comparison applied between the resolved attribute and the right-hand side. Valid values: eq, neq, in, not_in (static: compare against values), eq_attr (cross-attribute: compare against rhsSource/rhsKey instead of values). + */ + operator: string; + /** + * Right-hand side of the comparison for static operators (eq, neq, in, not_in). Must be non-empty when present. Absent for cross-attribute operator eq_attr. + * + * @minItems 1 + */ + values?: [string, ...string[]]; + /** + * Cross-attribute right-hand-side namespace. Set only for operator eq_attr. Valid values: subject, resource, environment. + */ + rhsSource?: string; + /** + * Cross-attribute right-hand-side attribute name. Set only for operator eq_attr. + */ + rhsKey?: string; + }[]; + /** + * Obligations imposed on the PEP when this rule matches. All fields are optional. + */ + obligations?: { + /** + * Row-visibility obligation. Grantable values: self, device, tenant. The value 'all' (cross-tenant) is reserved for the audited super-admin derivation and is rejected with 422 when supplied via policy authoring. Absent means no row-scope constraint. + */ + rowScope?: string; + /** + * Fields the PEP must redact from the response. Absent or empty means no field-mask obligation. + */ + fieldMask?: string[]; + }; +} diff --git a/packages/contracts/src/http/policy/update/v1/request.ts b/packages/contracts/src/http/policy/update/v1/request.ts new file mode 100644 index 0000000..5114b0b --- /dev/null +++ b/packages/contracts/src/http/policy/update/v1/request.ts @@ -0,0 +1,82 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/policy/update/v1/request.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +/** + * Compare-and-swap guard. Must equal the resource's current version; mismatch returns 409 ERR_VERSION_CONFLICT. + */ +export type GoCellCASExpectedVersionGuard = number; + +export interface HttpPolicyUpdateV1Request { + name: string; + description?: string; + /** + * @minItems 1 + */ + rules: [PolicyRule, ...PolicyRule[]]; + expectedVersion: GoCellCASExpectedVersionGuard; +} +/** + * A single ABAC rule within a Policy. Referenced by policy create/update request schemas and policy get/list/create/update response schemas. + */ +export interface PolicyRule { + /** + * Stable client-assigned rule identifier (scoped to the policy). + */ + id: string; + /** + * Human-readable rule label. + */ + name: string; + /** + * Authorization verdict. Valid values: allow, deny. Deny-overrides: any deny wins over all allows. + */ + effect: string; + /** + * All conditions are AND-combined. Empty or absent means unconditional (rule always applies). + */ + conditions?: { + /** + * Attribute namespace. Valid values: subject (principal claims), resource (protected object attrs), environment (context). + */ + source: string; + /** + * Attribute name within the source namespace (e.g. department, classification, time_of_day). + */ + key: string; + /** + * Comparison applied between the resolved attribute and the right-hand side. Valid values: eq, neq, in, not_in (static: compare against values), eq_attr (cross-attribute: compare against rhsSource/rhsKey instead of values). + */ + operator: string; + /** + * Right-hand side of the comparison for static operators (eq, neq, in, not_in). Must be non-empty when present. Absent for cross-attribute operator eq_attr. + * + * @minItems 1 + */ + values?: [string, ...string[]]; + /** + * Cross-attribute right-hand-side namespace. Set only for operator eq_attr. Valid values: subject, resource, environment. + */ + rhsSource?: string; + /** + * Cross-attribute right-hand-side attribute name. Set only for operator eq_attr. + */ + rhsKey?: string; + }[]; + /** + * Obligations imposed on the PEP when this rule matches. All fields are optional. + */ + obligations?: { + /** + * Row-visibility obligation. Grantable values: self, device, tenant. The value 'all' (cross-tenant) is reserved for the audited super-admin derivation and is rejected with 422 when supplied via policy authoring. Absent means no row-scope constraint. + */ + rowScope?: string; + /** + * Fields the PEP must redact from the response. Absent or empty means no field-mask obligation. + */ + fieldMask?: string[]; + }; +} diff --git a/packages/contracts/src/http/policy/update/v1/response.ts b/packages/contracts/src/http/policy/update/v1/response.ts new file mode 100644 index 0000000..16b772a --- /dev/null +++ b/packages/contracts/src/http/policy/update/v1/response.ts @@ -0,0 +1,84 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/http/policy/update/v1/response.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +export interface HttpPolicyUpdateV1Response { + data: Policy; +} +/** + * ABAC Policy response object. Deliberately carries no createdAt/updatedAt — the Policy domain uses CAS version for conflict detection, not timestamps. + */ +export interface Policy { + id: string; + name: string; + description?: string; + /** + * Monotonically increasing CAS version. + */ + version: number; + rules: PolicyRule[]; +} +/** + * A single ABAC rule within a Policy. Referenced by policy create/update request schemas and policy get/list/create/update response schemas. + */ +export interface PolicyRule { + /** + * Stable client-assigned rule identifier (scoped to the policy). + */ + id: string; + /** + * Human-readable rule label. + */ + name: string; + /** + * Authorization verdict. Valid values: allow, deny. Deny-overrides: any deny wins over all allows. + */ + effect: string; + /** + * All conditions are AND-combined. Empty or absent means unconditional (rule always applies). + */ + conditions?: { + /** + * Attribute namespace. Valid values: subject (principal claims), resource (protected object attrs), environment (context). + */ + source: string; + /** + * Attribute name within the source namespace (e.g. department, classification, time_of_day). + */ + key: string; + /** + * Comparison applied between the resolved attribute and the right-hand side. Valid values: eq, neq, in, not_in (static: compare against values), eq_attr (cross-attribute: compare against rhsSource/rhsKey instead of values). + */ + operator: string; + /** + * Right-hand side of the comparison for static operators (eq, neq, in, not_in). Must be non-empty when present. Absent for cross-attribute operator eq_attr. + * + * @minItems 1 + */ + values?: [string, ...string[]]; + /** + * Cross-attribute right-hand-side namespace. Set only for operator eq_attr. Valid values: subject, resource, environment. + */ + rhsSource?: string; + /** + * Cross-attribute right-hand-side attribute name. Set only for operator eq_attr. + */ + rhsKey?: string; + }[]; + /** + * Obligations imposed on the PEP when this rule matches. All fields are optional. + */ + obligations?: { + /** + * Row-visibility obligation. Grantable values: self, device, tenant. The value 'all' (cross-tenant) is reserved for the audited super-admin derivation and is rejected with 422 when supplied via policy authoring. Absent means no row-scope constraint. + */ + rowScope?: string; + /** + * Fields the PEP must redact from the response. Absent or empty means no field-mask obligation. + */ + fieldMask?: string[]; + }; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index cd27bc2..bda9f6a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -5,6 +5,7 @@ * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 */ +export type { HttpAdminHealthCellsV1Response } from './http/admin/health/cells/v1/response' export type { HttpAuditListV1Response } from './http/audit/list/v1/response' export type { HttpAuthLoginV1Request } from './http/auth/login/v1/request' export type { HttpAuthLoginV1Response } from './http/auth/login/v1/response' @@ -60,5 +61,21 @@ export type { HttpConfigUpdateV1Request } from './http/config/update/v1/request' export type { HttpConfigUpdateV1Response } from './http/config/update/v1/response' export type { HttpConfigWriteV1Request } from './http/config/write/v1/request' export type { HttpConfigWriteV1Response } from './http/config/write/v1/response' +export type { HttpDevicecomplianceV1Response } from './http/devicecompliance/v1/response' +export type { HttpDeviceidentityEnrollV1Request } from './http/deviceidentity/enroll/v1/request' +export type { HttpDeviceidentityEnrollV1Response } from './http/deviceidentity/enroll/v1/response' +export type { HttpDeviceidentityRenewV1Request } from './http/deviceidentity/renew/v1/request' +export type { HttpDeviceidentityRenewV1Response } from './http/deviceidentity/renew/v1/response' +export type { HttpDeviceidentityRevokeV1Request } from './http/deviceidentity/revoke/v1/request' +export type { HttpDeviceidentityRevokeV1Response } from './http/deviceidentity/revoke/v1/response' +export type { HttpDeviceidentityStatusV1Response } from './http/deviceidentity/status/v1/response' +export type { HttpDevicestateV1Response } from './http/devicestate/v1/response' +export type { HttpPolicyCreateV1Request, PolicyRule } from './http/policy/create/v1/request' +export type { HttpPolicyCreateV1Response, Policy } from './http/policy/create/v1/response' +export type { HttpPolicyGetV1Response } from './http/policy/get/v1/response' +export type { HttpPolicyListV1Response } from './http/policy/list/v1/response' +export type { HttpPolicyUpdateV1Request } from './http/policy/update/v1/request' +export type { HttpPolicyUpdateV1Response } from './http/policy/update/v1/response' export type { GoCellCASExpectedVersionGuard } from './shared/cas/v1/expected_version' +export type { DeviceidentityCertificateIdentity } from './shared/deviceidentity/v1/certificate-identity' export type { GoCellHTTPErrorResponse } from './shared/errors/error-response-v1' diff --git a/packages/contracts/src/shared/deviceidentity/v1/certificate-identity.ts b/packages/contracts/src/shared/deviceidentity/v1/certificate-identity.ts new file mode 100644 index 0000000..4004a6f --- /dev/null +++ b/packages/contracts/src/shared/deviceidentity/v1/certificate-identity.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED — DO NOT EDIT BY HAND. + * Source: gocell/contracts/shared/deviceidentity/v1/certificate-identity.schema.json + * 由 `pnpm codegen` 派生;CI 经 `git diff --exit-code` 守门只读。 + */ + +/** + * Provider-neutral X.509 certificate identity: an issuing CA identifier plus a serial number uniquely identify a certificate (RFC 5280 — issuer name + serial, not a globally-unique serial). Single source shared by deviceidentity status/revoke contracts and the cert-issued/cert-revoked events so every endpoint references the same identity shape. ref: RFC 5280 §4.1.2.2. + */ +export interface DeviceidentityCertificateIdentity { + /** + * Issuing CA identifier; with serial forms the X.509 unique identity (RFC 5280 issuer + serial). + */ + issuer: string; + /** + * Certificate serial number, hex (RFC 5280 §4.1.2.2). + */ + serial: string; +} diff --git a/packages/devboard/README.md b/packages/devboard/README.md index 6fbdb6c..26f0cff 100644 --- a/packages/devboard/README.md +++ b/packages/devboard/README.md @@ -2,7 +2,7 @@ > 开发者平台聚合视图:Cells / Groups / Coverage / Contracts / Deps(Batch 5/6) > -> **对应后端 cell**:无(聚合派生视图)。Cells 数据由构建期从后端 `../gocell/cells/*/cell.yaml` + `slices/*/slice.yaml` 派生(见下)。 +> **对应后端 cell**:无(聚合派生视图)。Cells 数据由构建期从后端 `../gocell/corecells/*/cell.yaml` + `slices/*/slice.yaml` 派生(见下)。 ## 对外 exports @@ -35,7 +35,7 @@ PDP 门:后端无 `contract`/`dep`/`group` resource,路由守卫降级到 `r `tools/cell-manifest/` 镜像 `tools/codegen` 模式:构建期读后端 `cell.yaml` + `slice.yaml` → 派生 `src/manifest/cells.generated.ts`(`/* eslint-disable */` + DO-NOT-EDIT banner,prettier-ignored)。 - 单源派生 + CI `git diff --exit-code`(`.github/workflows/cell-manifest-diff.yml`)守门,业务包手改生成物即红 → 违反不可表达(Hard)。 -- 本地重跑:`GOCELL_CELLS_DIR=../gocell/cells pnpm cell-manifest`(CI 把 `ghbvf/gocell` checkout 为同级目录,默认路径生效)。 +- 本地重跑:`GOCELL_CELLS_DIR=../gocell/corecells pnpm cell-manifest`(CI 把 `ghbvf/gocell` checkout 为同级目录,默认路径生效)。 - `slice.yaml` 的 `contractUsages[].role`:`serve`/`publish` → cell **produces**;`call`/`subscribe` → **consumes**;跨 cell 的 consume→produce 解析出 `dependsOnCells` / `requiredByCells`。 - **不可派生字段**(运行时 QPS/p95/健康分、tasks、SLOC、version、oncall)→ 显式降级为 "—" / `UnavailablePanel`,**绝不伪造**。需后端健康端点(BR-001)才补。 diff --git a/packages/devboard/src/manifest/cells.generated.ts b/packages/devboard/src/manifest/cells.generated.ts index 3709d16..07c7eb7 100644 --- a/packages/devboard/src/manifest/cells.generated.ts +++ b/packages/devboard/src/manifest/cells.generated.ts @@ -1,6 +1,6 @@ /* eslint-disable */ // AUTO-GENERATED by tools/cell-manifest — DO NOT EDIT BY HAND. -// Source: gocell/cells/** +// Source: gocell/corecells/** // Regenerate: pnpm cell-manifest (CI guards via git diff --exit-code) import type { CellManifest } from './types' @@ -149,6 +149,50 @@ export const CELL_MANIFEST: CellManifest = { ], "waivers": [] }, + { + "id": "policymanage", + "belongsToCell": "accesscore", + "consistencyLevel": "L2", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "event.policy.updated.v1", + "role": "publish" + }, + { + "contract": "http.policy.create.v1", + "role": "serve" + }, + { + "contract": "http.policy.get.v1", + "role": "serve" + }, + { + "contract": "http.policy.update.v1", + "role": "serve" + }, + { + "contract": "http.policy.delete.v1", + "role": "serve" + }, + { + "contract": "http.policy.list.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.policymanage.service" + ], + "contractTests": [ + "contract.event.policy.updated.v1.publish", + "contract.http.policy.create.v1.serve", + "contract.http.policy.get.v1.serve", + "contract.http.policy.update.v1.serve", + "contract.http.policy.delete.v1.serve", + "contract.http.policy.list.v1.serve" + ], + "waivers": [] + }, { "id": "rbacassign", "belongsToCell": "accesscore", @@ -368,6 +412,10 @@ export const CELL_MANIFEST: CellManifest = { "contract": "event.auth.bootstrap-failed.v1", "role": "publish" }, + { + "contract": "event.policy.updated.v1", + "role": "publish" + }, { "contract": "event.role.assigned.v1", "role": "publish" @@ -471,6 +519,26 @@ export const CELL_MANIFEST: CellManifest = { { "contract": "http.auth.user.update.v1", "role": "serve" + }, + { + "contract": "http.policy.create.v1", + "role": "serve" + }, + { + "contract": "http.policy.delete.v1", + "role": "serve" + }, + { + "contract": "http.policy.get.v1", + "role": "serve" + }, + { + "contract": "http.policy.list.v1", + "role": "serve" + }, + { + "contract": "http.policy.update.v1", + "role": "serve" } ], "consumes": [ @@ -1106,7 +1174,57 @@ export const CELL_MANIFEST: CellManifest = { "accesscore", "auditcore" ] + }, + { + "id": "syscore", + "name": "SysCore", + "domain": "Sys", + "type": "support", + "consistencyLevel": "L1", + "lifecycle": "asset", + "durabilityMode": "durable", + "owner": { + "team": "platform", + "role": "cell-owner" + }, + "goStructName": "SysCore", + "schemaPrimary": "cell_syscore", + "requires": [], + "l0Dependencies": [], + "smokeTests": [ + "smoke.syscore.startup" + ], + "slices": [ + { + "id": "healthread", + "belongsToCell": "syscore", + "consistencyLevel": "L0", + "lifecycle": "asset", + "contractUsages": [ + { + "contract": "http.admin.health.cells.v1", + "role": "serve" + } + ], + "unitTests": [ + "unit.healthread.service" + ], + "contractTests": [ + "contract.http.admin.health.cells.v1.serve" + ], + "waivers": [] + } + ], + "produces": [ + { + "contract": "http.admin.health.cells.v1", + "role": "serve" + } + ], + "consumes": [], + "dependsOnCells": [], + "requiredByCells": [] } ], - "generatedFrom": "gocell/cells/**/{cell.yaml,slices/*/slice.yaml}" + "generatedFrom": "gocell/corecells/**/{cell.yaml,slices/*/slice.yaml}" } diff --git a/packages/devboard/src/stores/useCellsStore.spec.ts b/packages/devboard/src/stores/useCellsStore.spec.ts index 52722e8..0fb67d4 100644 --- a/packages/devboard/src/stores/useCellsStore.spec.ts +++ b/packages/devboard/src/stores/useCellsStore.spec.ts @@ -13,10 +13,10 @@ describe('useCellsStore', () => { expect(store.selectedId).toBeNull() }) - it('cells returns all manifest cells (length 3)', () => { + it('cells returns all manifest cells', () => { const store = useCellsStore() expect(store.cells).toBe(CELL_MANIFEST.cells) - expect(store.cells.length).toBe(3) + expect(store.cells.length).toBe(CELL_MANIFEST.cells.length) }) it('byId returns a known cell entry', () => { diff --git a/tools/cell-manifest/README.md b/tools/cell-manifest/README.md index 7956241..1c22ac5 100644 --- a/tools/cell-manifest/README.md +++ b/tools/cell-manifest/README.md @@ -1,6 +1,6 @@ # @gocell/cell-manifest -> Cell manifest 单向派生器:后端 `gocell/cells/*/cell.yaml` + `slices/*/slice.yaml` → `packages/devboard/src/manifest/cells.generated.ts`。 +> Cell manifest 单向派生器:后端 `gocell/corecells/*/cell.yaml` + `slices/*/slice.yaml` → `packages/devboard/src/manifest/cells.generated.ts`。 是 AI-robust「Hard」约束的执行体(`ai-robust.md` §载体决策原则 第 1 条):单源 YAML 派生 + CI `git diff --exit-code` 守门(`.github/workflows/cell-manifest-diff.yml`),业务包手改生成物在 CI 不可表达。生成文件带 `: CellManifest` 类型注解(来自 devboard 的规范 `./types`),任何形状漂移在 `@gocell/devboard typecheck` 失败。 @@ -10,7 +10,7 @@ pnpm cell-manifest # = pnpm -F @gocell/cell-manifest generate ``` -- **源路径**:默认 `/../gocell/cells`(gocell-web 与后端 gocell 同级 checkout;CI 亦如此布局)。可经环境变量 `GOCELL_CELLS_DIR` 覆盖(如 git worktree 本地开发:`GOCELL_CELLS_DIR=../gocell/cells pnpm cell-manifest`)。 +- **源路径**:默认 `/../gocell/corecells`(gocell-web 与后端 gocell 同级 checkout;CI 亦如此布局)。可经环境变量 `GOCELL_CELLS_DIR` 覆盖(如 git worktree 本地开发:`GOCELL_CELLS_DIR=../gocell/corecells pnpm cell-manifest`)。 - **产物**:`packages/devboard/src/manifest/cells.generated.ts`,带 `/* eslint-disable */` + DO-NOT-EDIT banner(已加入 `eslint.config.js` ignores + `.prettierignore`)。 ## 派生规则 diff --git a/tools/cell-manifest/src/derive.spec.ts b/tools/cell-manifest/src/derive.spec.ts index a4279ad..b0d9cfa 100644 --- a/tools/cell-manifest/src/derive.spec.ts +++ b/tools/cell-manifest/src/derive.spec.ts @@ -563,7 +563,7 @@ describe('buildManifest', () => { it('generatedFrom is the static note (deterministic, no timestamp)', () => { const manifest = buildManifest([]) - expect(manifest.generatedFrom).toBe('gocell/cells/**/{cell.yaml,slices/*/slice.yaml}') + expect(manifest.generatedFrom).toBe('gocell/corecells/**/{cell.yaml,slices/*/slice.yaml}') }) it('[blind-spot] determinism: buildManifest twice on same input → deep-equal', () => { @@ -657,7 +657,7 @@ describe('renderManifestModule', () => { it('produces a TS module string with CELL_MANIFEST export', () => { const manifest: CellManifest = { cells: [], - generatedFrom: 'gocell/cells/**/{cell.yaml,slices/*/slice.yaml}', + generatedFrom: 'gocell/corecells/**/{cell.yaml,slices/*/slice.yaml}', } const output = renderManifestModule(manifest) expect(output).toContain('/* eslint-disable */') diff --git a/tools/cell-manifest/src/derive.ts b/tools/cell-manifest/src/derive.ts index d13b4bd..8e86504 100644 --- a/tools/cell-manifest/src/derive.ts +++ b/tools/cell-manifest/src/derive.ts @@ -313,7 +313,7 @@ export function buildManifest(rawCells: RawCellWithSlices[]): CellManifest { return { cells: finalEntries, - generatedFrom: 'gocell/cells/**/{cell.yaml,slices/*/slice.yaml}', + generatedFrom: 'gocell/corecells/**/{cell.yaml,slices/*/slice.yaml}', } } @@ -327,7 +327,7 @@ export function renderManifestModule(m: CellManifest): string { return [ '/* eslint-disable */', '// AUTO-GENERATED by tools/cell-manifest — DO NOT EDIT BY HAND.', - '// Source: gocell/cells/**', + '// Source: gocell/corecells/**', '// Regenerate: pnpm cell-manifest (CI guards via git diff --exit-code)', "import type { CellManifest } from './types'", '', diff --git a/tools/cell-manifest/src/index.ts b/tools/cell-manifest/src/index.ts index 0755842..f65a85d 100644 --- a/tools/cell-manifest/src/index.ts +++ b/tools/cell-manifest/src/index.ts @@ -2,7 +2,7 @@ * @gocell/cell-manifest — IO orchestration layer. * * Reads cell.yaml + slices/[x]/slice.yaml from GOCELL_CELLS_DIR (defaults to - * ../gocell/cells relative to repo root), derives CellManifest via pure + * ../gocell/corecells relative to repo root), derives CellManifest via pure * derive.ts functions, and writes the generated TS module to * packages/devboard/src/manifest/cells.generated.ts. * @@ -18,7 +18,7 @@ import { buildManifest, renderManifestModule } from './derive' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const REPO_ROOT = resolve(SCRIPT_DIR, '../../..') -const CELLS_DIR = resolve(process.env['GOCELL_CELLS_DIR'] ?? join(REPO_ROOT, '../gocell/cells')) +const CELLS_DIR = resolve(process.env['GOCELL_CELLS_DIR'] ?? join(REPO_ROOT, '../gocell/corecells')) const OUT = join(REPO_ROOT, 'packages/devboard/src/manifest/cells.generated.ts') function main(): void { @@ -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.`, ) } From d81f3b7b0b5dd923ad88857972edae628debe505 Mon Sep 17 00:00:00 2001 From: ghbvf <104540935+ghbvf@users.noreply.github.com> Date: Thu, 18 Jun 2026 04:52:21 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat(access):=20PDP=20=E6=8E=A5=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=90=8E=E7=AB=AF=20/api/v1/access/decide=EF=BC=88?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=20mock=20=E5=86=B3=E7=AD=96=E6=BA=90?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 PDP 端点(http.auth.decide.v1,gocell#1863)已上线,决策源从占位 mock 切换到真实 HTTP,并补齐 UI 词表 → 后端注册权限名的翻译层。 - 新增 `createHttpDecide()`:接 `POST /api/v1/access/decide`,响应 `{ data: { allowed } }` 映射回 `Decision`;HTTP 失败(400 未注册 action / 403 / 503)→ 抛出 → createPdpClient 链路 fail-closed deny。 - 新增 `toPermission(action, resource)`(permissionMap):UI 细粒度 (action, resource) → 后端 `:` 权限名。资源域名对齐 (identity→user、cell→system),写类动作收敛到 `:write`,未登记 → best-effort 拼名 → 后端 400 fail-closed。真相源 backend authz/permission.go。 - `createPdpClient` 默认决策源由 mock allow-all 改为 **fail-closed deny-all**, 杜绝忘记装配导致的 fail-open;`apps/web/main.ts` 显式注入 `createHttpDecide()`。 - 抽 `DecideFn`/`DecisionRequest` 到 `decideSource.ts`(mock ↔ real 公共缝)。 - 删除 `mockDecide`(后端已上线,占位源退场)。 - 单测:permissionMap 全 (action,resource) 对 + httpDecide 请求/映射/错误。 Refs #50 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/main.ts | 8 +- packages/access/README.md | 23 +++-- packages/access/src/index.ts | 1 + .../access/src/pdp/createPdpClient.spec.ts | 11 ++- packages/access/src/pdp/createPdpClient.ts | 24 +++-- packages/access/src/pdp/decideSource.ts | 18 ++++ packages/access/src/pdp/httpDecide.spec.ts | 64 +++++++++++++ packages/access/src/pdp/httpDecide.ts | 34 +++++++ packages/access/src/pdp/index.ts | 1 + packages/access/src/pdp/mockDecide.spec.ts | 93 ------------------- packages/access/src/pdp/mockDecide.ts | 71 -------------- packages/access/src/pdp/permissionMap.spec.ts | 82 ++++++++++++++++ packages/access/src/pdp/permissionMap.ts | 73 +++++++++++++++ 13 files changed, 316 insertions(+), 187 deletions(-) create mode 100644 packages/access/src/pdp/decideSource.ts create mode 100644 packages/access/src/pdp/httpDecide.spec.ts create mode 100644 packages/access/src/pdp/httpDecide.ts delete mode 100644 packages/access/src/pdp/mockDecide.spec.ts delete mode 100644 packages/access/src/pdp/mockDecide.ts create mode 100644 packages/access/src/pdp/permissionMap.spec.ts create mode 100644 packages/access/src/pdp/permissionMap.ts diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts index 9fe0492..62eb509 100644 --- a/apps/web/src/main.ts +++ b/apps/web/src/main.ts @@ -7,7 +7,7 @@ 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' @@ -24,8 +24,10 @@ 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). PDP deny → push the 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/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 cca008e..013db37 100644 --- a/packages/access/src/pdp/createPdpClient.spec.ts +++ b/packages/access/src/pdp/createPdpClient.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { flushPromises } from '@vue/test-utils' import type { Decision } from '@gocell/core' import { createPdpClient } from './createPdpClient' -import type { DecideFn } from './mockDecide' +import type { DecideFn } from './decideSource' const ALLOW: Decision = { effect: 'allow', reasonCode: '' } const DENY: Decision = { effect: 'deny', reasonCode: 'role-missing' } @@ -214,14 +214,17 @@ describe('createPdpClient (mock-first PDP)', () => { }) describe('default decision source (no options)', () => { - it('uses the admin mock → allow for any action/resource', async () => { + it('fails closed → deny for any action when no decide source is injected', async () => { const client = createPdpClient() - expect((await client.decide('delete', 'config')).effect).toBe('allow') + 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(true) + 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 d921cfe..d302a33 100644 --- a/packages/access/src/pdp/createPdpClient.ts +++ b/packages/access/src/pdp/createPdpClient.ts @@ -1,10 +1,10 @@ /** - * Mock-first PDP client(issue #50 / BR-004 §4.1)。 + * PDP client(issue #50 / BR-004 §4.1)。 * - * 决策源默认走本地 mock(createMockDecide)——后端 `POST /api/v1/access/decide` - * 尚未上线(gocell#1863)。按 epic #62「mock-first」原则前端不阻塞:client 全链路 - * 就绪——响应式缓存 + TTL 失效 + 单飞 + 异步结构化决策 + fail-closed。后端端点交付后 - * 仅需 `createPdpClient({ decide: realDecideFn })` 注入对真实端点的调用,其余不变。 + * 决策源由装配层注入:生产用 `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。 @@ -14,10 +14,16 @@ import { computed, reactive } from 'vue' import type { ComputedRef } from 'vue' import type { Decision, PdpClient } from '@gocell/core' -import { createMockDecide, type DecideFn } from './mockDecide' +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 { decision: Decision fetchedAt: number @@ -34,8 +40,8 @@ function cacheKey(action: string, resource: string | undefined): string { export interface PdpClientOptions { /** - * 决策源。默认 mock-first 解析器(createMockDecide())。 - * 后端 `POST /api/v1/access/decide` 上线后,注入对真实端点的调用即可替换(BR-004 §4.1)。 + * 决策源。生产由装配层注入 `createHttpDecide()`(接后端 `/api/v1/access/decide`)。 + * 省略时 fail-closed deny-all 兜底(不 fail-open)。测试可注入 fake(BR-004 §4.1)。 */ decide?: DecideFn } @@ -52,7 +58,7 @@ export interface PdpClientOptions { * - ComputedRef 缓存:同 key 始终返回同一 ComputedRef 实例,避免重复 can() 调用堆积。 */ export function createPdpClient(options: PdpClientOptions = {}): PdpClient { - const decideFn: DecideFn = options.decide ?? createMockDecide() + const decideFn: DecideFn = options.decide ?? denyAllDecide const store = reactive({ entries: {}, expiryTick: 0 }) const inFlight = new Map>() const computedCache = new Map>() 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/mockDecide.spec.ts b/packages/access/src/pdp/mockDecide.spec.ts deleted file mode 100644 index 826e5fd..0000000 --- a/packages/access/src/pdp/mockDecide.spec.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { ADMIN_GRANT, VIEWER_GRANT, evaluateGrant, createMockDecide } from './mockDecide' - -describe('mockDecide — deterministic RBAC mock decision source', () => { - describe('evaluateGrant', () => { - it('ADMIN_GRANT (*:*) allows any action on any resource', () => { - expect(evaluateGrant(ADMIN_GRANT, { action: 'read', resource: 'identity' })).toEqual({ - effect: 'allow', - reasonCode: '', - }) - expect(evaluateGrant(ADMIN_GRANT, { action: 'delete', resource: 'config' })).toEqual({ - effect: 'allow', - reasonCode: '', - }) - }) - - it('ADMIN_GRANT allows an action with no resource', () => { - expect(evaluateGrant(ADMIN_GRANT, { action: 'list' })).toEqual({ - effect: 'allow', - reasonCode: '', - }) - }) - - it('VIEWER_GRANT (read:*) allows read on any resource', () => { - expect(evaluateGrant(VIEWER_GRANT, { action: 'read', resource: 'audit' })).toEqual({ - effect: 'allow', - reasonCode: '', - }) - }) - - it('VIEWER_GRANT denies write/delete with reasonCode role-missing', () => { - expect(evaluateGrant(VIEWER_GRANT, { action: 'write', resource: 'config' })).toEqual({ - effect: 'deny', - reasonCode: 'role-missing', - }) - expect(evaluateGrant(VIEWER_GRANT, { action: 'delete', resource: 'identity' })).toEqual({ - effect: 'deny', - reasonCode: 'role-missing', - }) - }) - - it('matches an exact action:resource rule', () => { - const grant = ['write:config'] as const - expect(evaluateGrant(grant, { action: 'write', resource: 'config' }).effect).toBe('allow') - expect(evaluateGrant(grant, { action: 'write', resource: 'flag' }).effect).toBe('deny') - expect(evaluateGrant(grant, { action: 'read', resource: 'config' }).effect).toBe('deny') - }) - - it('action wildcard matches any action on a fixed resource', () => { - const grant = ['*:config'] as const - expect(evaluateGrant(grant, { action: 'read', resource: 'config' }).effect).toBe('allow') - expect(evaluateGrant(grant, { action: 'delete', resource: 'config' }).effect).toBe('allow') - expect(evaluateGrant(grant, { action: 'read', resource: 'flag' }).effect).toBe('deny') - }) - - it('empty grant denies everything', () => { - expect(evaluateGrant([], { action: 'read', resource: 'cell' })).toEqual({ - effect: 'deny', - reasonCode: 'role-missing', - }) - }) - - it('ignores malformed rules without a colon (fail-closed)', () => { - const grant = ['read'] as const // no ':' → never matches - expect(evaluateGrant(grant, { action: 'read', resource: '' }).effect).toBe('deny') - }) - - it('a rule with an empty resource matches a request with no resource', () => { - const grant = ['read:'] as const - expect(evaluateGrant(grant, { action: 'read' }).effect).toBe('allow') - expect(evaluateGrant(grant, { action: 'read', resource: 'cell' }).effect).toBe('deny') - }) - }) - - describe('createMockDecide', () => { - it('defaults to the admin grant → allow', async () => { - const decide = createMockDecide() - expect(await decide({ action: 'delete', resource: 'config' })).toEqual({ - effect: 'allow', - reasonCode: '', - }) - }) - - it('honors a custom grant → deny for ungranted actions', async () => { - const decide = createMockDecide(VIEWER_GRANT) - expect(await decide({ action: 'write', resource: 'flag' })).toEqual({ - effect: 'deny', - reasonCode: 'role-missing', - }) - expect((await decide({ action: 'read', resource: 'flag' })).effect).toBe('allow') - }) - }) -}) diff --git a/packages/access/src/pdp/mockDecide.ts b/packages/access/src/pdp/mockDecide.ts deleted file mode 100644 index 6c86761..0000000 --- a/packages/access/src/pdp/mockDecide.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { Decision } from '@gocell/core' - -/** - * Mock-first PDP 决策源——替代尚未上线的后端 `POST /api/v1/access/decide` - * (BR-004 §4.1,后端跟踪 gocell#1863)。 - * - * 前端按 epic #62「mock-first」原则不等后端:本模块用确定性 RBAC grant 评估 - * (action, resource),返回 BR-004 §3.2 `Decision` 的前端消费子集。后端端点交付后, - * 把 createPdpClient 的默认 `decide` 换成对真实端点的调用(响应映射回 `Decision`)即可, - * 本模块整体删除——其余 PDP 链路(缓存 / TTL / 守卫 / )不变。 - */ - -/** PDP 决策入参。对标 BR-004 §3.1 请求的 MVP 子集(仅 action + 可选 resource)。 */ -export interface DecisionRequest { - action: string - // 显式允许 undefined:can()/decide() 的 resource 可省略,透传到此处(exactOptionalPropertyTypes)。 - resource?: string | undefined -} - -/** - * 决策函数签名。注入到 createPdpClient,是「mock ↔ 真实后端」的唯一替换缝。 - */ -export type DecideFn = (req: DecisionRequest) => Promise - -/** - * 授权 grant:`":"` 规则集合,`*` 为通配。 - * 形如 `"*:*"`(全放行)/ `"read:*"`(只读全资源)/ `"write:config"`(精确)。 - * 命中任一规则即 allow。 - */ -export type Grant = readonly string[] - -/** - * MVP 默认主体 = first-run 创建的唯一 admin → 全量授权。 - * 单管理员部署下管理员本就该看到一切,故默认 grant-all 是诚实建模而非"假放行"; - * deny 分支由受限 grant(如 VIEWER_GRANT)在测试 / 演示中真实触发。 - */ -export const ADMIN_GRANT: Grant = ['*:*'] - -/** 只读演示 grant:仅 read 全资源;写操作 → deny(驱动 隐藏写按钮)。 */ -export const VIEWER_GRANT: Grant = ['read:*'] - -/** 单条 grant 规则是否命中 (action, resource)。非法规则(无 `:`)→ 不匹配(fail-closed)。 */ -function ruleMatches(rule: string, action: string, resource: string): boolean { - const sep = rule.indexOf(':') - if (sep < 0) return false - const ruleAction = rule.slice(0, sep) - const ruleResource = rule.slice(sep + 1) - const actionOk = ruleAction === '*' || ruleAction === action - const resourceOk = ruleResource === '*' || ruleResource === resource - return actionOk && resourceOk -} - -/** - * 用 grant 评估 (action, resource) → 结构化 Decision。 - * 命中 → allow;未命中 → deny(reasonCode `role-missing`,对应 i18n 拒绝文案)。 - */ -export function evaluateGrant(grant: Grant, req: DecisionRequest): Decision { - const resource = req.resource ?? '' - const allowed = grant.some((rule) => ruleMatches(rule, req.action, resource)) - return allowed - ? { effect: 'allow', reasonCode: '' } - : { effect: 'deny', reasonCode: 'role-missing' } -} - -/** - * 创建 mock 决策函数。默认 ADMIN_GRANT(MVP 单管理员 → 全量放行,全站路由可达)。 - * 测试 / 演示传入受限 grant 即可驱动真实 deny 路径。 - */ -export function createMockDecide(grant: Grant = ADMIN_GRANT): DecideFn { - return (req) => Promise.resolve(evaluateGrant(grant, req)) -} 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}` +}