Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/backend-requirements/BR-009-tenant-in-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# BR-009 · 会话响应暴露 `tenantId`(`http.auth.login` / `http.auth.refresh`)

| 字段 | 值 |
|---|---|
| 状态 | 待评审 |
| 优先级 | P1(解锁角色变更端到端;当前前端优雅降级,功能不可用) |
| 阻塞前端 | `/access/policies` 角色 assign/revoke(多租户角色 UX epic)|
| 提出方 | gocell-web |
| 估算 | 0.5 人天(tenant 已在 `sessions.tenant_id`,PR-3b #1678 已落库;仅需透出到响应信封) |
| 涉及 | `accesscore/sessions` slice;改 `contracts/http/auth/{login,refresh}/v1/response.schema.json` |

---

## 1. 背景

后端多租户 epic(#1337)PR-3b(#1678)已为 `http.auth.role.assign.v1` / `http.auth.role.revoke.v1`
请求新增**必填** `tenantId`(uuid),并已在 `sessions.tenant_id` 落库 + auth-path tenant wiring。

但**没有任何会话响应暴露该 tenantId**:`http.auth.login.v1.response` / `http.auth.refresh.v1.response`
的 `data` 仅含 `accessToken / refreshToken / expiresAt / sessionId / userId / passwordResetRequired`,
无 `tenantId`;`http.auth.user.*` 响应也不含。前端因此**无从得知当前会话的租户**,
无法为 role assign/revoke 提供必填的 `tenantId`。

> 前端处理(本仓 #66 / PR #201):`useAuthStore` 新增 `tenantId: string | null`(会话身份归属地,
> 恒为 `null` 直到本 BR 交付),`usePoliciesStore.assign/revoke` 在调 API 前守门——`tenantId` 为
> `null` 时抛 `TenantUnavailableError` 并展示 inline 文案 `access.policies.errors.tenantUnavailable`,
> **不发请求、不塞伪造值**。后端按本 BR 在响应透出 `tenantId` 后,前端在 `setSession` 填充
> `auth.tenantId`,角色变更即端到端可用,无需改动消费方。与 [BR-005](./BR-005-user-list.md) /
> [BR-004](./BR-004-access-pdp-evolution.md) 同模式(建到 ready,后端落地即接通)。

## 2. 接口规约

在以下两个**已有**响应的 `data` 对象内新增 `tenantId` 字段(其余字段不变):

### 2.1 `http.auth.login.v1.response` / `http.auth.refresh.v1.response`

`data` 新增:

```json
"tenantId": {
"type": "string",
"format": "uuid",
"description": "Tenant the session is bound to (sessions.tenant_id, epic #1337 PR-3b). Required for tenant-scoped mutations such as role assign/revoke."
}
```

#### 字段约束

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `data.tenantId` | string(uuid) | ✅(建议) | 会话所属租户;与 `role/assign`·`role/revoke` 请求要求的 `tenantId` 同一空间。单租户部署下亦应返回该用户的隐含租户 id。 |

> **必填 vs 可选**:建议设为必填(`required` 含 `tenantId`)——会话必然归属某租户。若后端存在
> tenant-less 系统会话(如 bootstrap),可设为可选并在文档注明空值语义;前端守门逻辑对二者均兼容。

## 3. 验收标准

- [ ] `login` / `refresh` 响应 `data` 含 `tenantId`(uuid),通过更新后的 `response.schema.json` 校验
- [ ] 前端 `pnpm codegen` 后 `HttpAuthLoginV1Response['data']` / `HttpAuthRefreshV1Response['data']` 出现 `tenantId`
- [ ] 前端 `setSession` 填充 `auth.tenantId` 后,`/access/policies` 的 assign/revoke 端到端成功(带正确 tenantId)
- [ ] 单租户与多租户部署下 `tenantId` 取值正确

## 4. 涉及代码区域

- `contracts/http/auth/login/v1/response.schema.json` + `contracts/http/auth/refresh/v1/response.schema.json`(新增 `tenantId`)
- `cells/accesscore/slices/sessions/*`(登录 / 续期 handler 透出 `session.TenantID` 到响应信封)

## 5. 前端联调

后端交付后,前端仅需在 `packages/access/src/stores/useAuthStore.ts` 的 `setSession` 填充:

```typescript
function setSession(payload: SessionData): void {
// ...existing fields...
tenantId.value = payload.tenantId
}
```

`usePoliciesStore` / `PoliciesView` 守门逻辑无需改动——`tenantId` 从 `null` 变为真实值后,
`TenantUnavailableError` 路径自然不再触发。
1 change: 1 addition & 0 deletions docs/backend-requirements/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| [BR-006](./BR-006-audit-hash-chain.md) | 审计行哈希链 + actor/result 元数据(`http.audit.list`) | 待评审 | 无(Batch 4 PR-13 已降级交付;解除后激活链校验) |
| [BR-007](./BR-007-flag-variant.md) | Feature Flag variant 定义与取值(`http.config.flags.*`) | 待评审 | 无(Batch 4 PR-14 bool flag 已交付;variant 占位) |
| [BR-008](./BR-008-config-version-history.md) | 配置项版本历史列表端点(`http.config.versions`) | 待评审 | 无(Batch 4 PR-14 rollback 手填版本已交付) |
| [BR-009](./BR-009-tenant-in-session.md) | 会话响应暴露 `tenantId`(`http.auth.login`/`refresh`) | 待评审 | `/access/policies` 角色 assign/revoke(#66 前端已优雅降级,待透出后接通) |

## 状态约定

Expand Down
12 changes: 8 additions & 4 deletions packages/access/src/api/roles.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,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-1', userId: 'u-1', roleId: 'role-1' }
mock.onPost(`${ROLES_URL}/assign`).reply(200, {
data: { userId: 'u-1', roleId: 'role-1', assigned: true },
})
Expand All @@ -63,7 +63,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-1', userId: 'u-1', roleId: 'role-1' }),
).rejects.toThrow()
})
})

Expand All @@ -79,7 +81,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-1', userId: 'u-1', roleId: 'role-1' }
mock.onPost(`${ROLES_URL}/revoke`).reply(200, {
data: { userId: 'u-1', roleId: 'role-1', revoked: true },
})
Expand All @@ -91,6 +93,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-1', userId: 'u-1', roleId: 'role-1' }),
).rejects.toThrow()
})
})
10 changes: 10 additions & 0 deletions packages/access/src/stores/useAuthStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ describe('useAuthStore', () => {
expect(store.accessToken).toBeNull()
// refreshToken is internal (write-only from outside) — not exposed on store
expect(store.passwordResetRequired).toBe(false)
// tenantId has no session-contract source yet (BR-009) — null until then.
expect(store.tenantId).toBeNull()
})

it('setSession sets user, accessToken and passwordResetRequired correctly', () => {
Expand Down Expand Up @@ -76,6 +78,14 @@ describe('useAuthStore', () => {
expect(store.passwordResetRequired).toBe(false)
})

it('clearSession resets tenantId to null', () => {
const store = useAuthStore()
// Simulate a future session that carries a tenant (BR-009).
store.tenantId = 'tenant-abc'
store.clearSession()
expect(store.tenantId).toBeNull()
})

// Security: access token must NEVER touch localStorage
it('localStorage is always empty after setSession (token not persisted)', () => {
const store = useAuthStore()
Expand Down
7 changes: 7 additions & 0 deletions packages/access/src/stores/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export const useAuthStore = defineStore('access.auth', () => {
// sessionId is internal; only logout() reads it to revoke the session server-side
const _sessionId = ref<string | null>(null)
const passwordResetRequired = ref(false)
// Tenant the session belongs to — needed by tenant-scoped mutations (e.g.
// accesscore role assign/revoke). The session contract does not expose it
// yet (BR-009), so it stays null until setSession can populate it; consumers
// must guard the null case rather than send a fabricated tenant.
const tenantId = ref<string | null>(null)

// ─── getters ──────────────────────────────────────────────────────────────
const isAuthenticated = computed(() => accessToken.value !== null)
Expand All @@ -55,6 +60,7 @@ export const useAuthStore = defineStore('access.auth', () => {
_refreshToken.value = null
_sessionId.value = null
passwordResetRequired.value = false
tenantId.value = null
}

/**
Expand Down Expand Up @@ -124,6 +130,7 @@ export const useAuthStore = defineStore('access.auth', () => {
user,
accessToken,
passwordResetRequired,
tenantId,
// getters
isAuthenticated,
// actions
Expand Down
50 changes: 45 additions & 5 deletions packages/access/src/stores/usePoliciesStore.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { usePoliciesStore } from './usePoliciesStore'
import { usePoliciesStore, TenantUnavailableError } 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
Expand All @@ -25,6 +26,9 @@ describe('usePoliciesStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.resetAllMocks()
// Default: a tenant is in session so mutations reach the API. The
// tenant-missing guard is exercised explicitly below by setting it null.
useAuthStore().tenantId = 'tenant-1'
})

it('starts with empty state', () => {
Expand Down Expand Up @@ -155,11 +159,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-1',
userId: 'u-1',
roleId: 'role-1',
})
expect(listUserRoles).toHaveBeenCalledWith('u-1')
expect(store.roles).toHaveLength(1)
})

it('throws TenantUnavailableError and does NOT call the API when no tenant in session', async () => {
useAuthStore().tenantId = null
const store = usePoliciesStore()
store.userId = 'u-1'

await expect(store.assign('role-1')).rejects.toBeInstanceOf(TenantUnavailableError)
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'))

Expand Down Expand Up @@ -226,7 +244,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-1',
userId: 'u-1',
roleId: 'role-1',
})
})
})

Expand All @@ -239,10 +261,24 @@ 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-1',
userId: 'u-1',
roleId: 'role-1',
})
expect(listUserRoles).toHaveBeenCalledWith('u-1')
})

it('throws TenantUnavailableError and does NOT call the API when no tenant in session', async () => {
useAuthStore().tenantId = null
const store = usePoliciesStore()
store.userId = 'u-1'

await expect(store.revoke('role-1')).rejects.toBeInstanceOf(TenantUnavailableError)
expect(revokeRole).not.toHaveBeenCalled()
expect(store.mutating).toBe(false)
})

it('re-throws on failure and does NOT set errorKey', async () => {
vi.mocked(revokeRole).mockRejectedValueOnce(new Error('revoke failed'))

Expand Down Expand Up @@ -288,7 +324,11 @@ describe('usePoliciesStore', () => {
await p1

expect(revokeRole).toHaveBeenCalledTimes(1)
expect(revokeRole).toHaveBeenCalledWith({ userId: 'u-1', roleId: 'role-1' })
expect(revokeRole).toHaveBeenCalledWith({
tenantId: 'tenant-1',
userId: 'u-1',
roleId: 'role-1',
})
})
})
})
27 changes: 25 additions & 2 deletions packages/access/src/stores/usePoliciesStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ 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'

/**
* Thrown by assign / revoke when no tenant is in session, so the backend's
* now-required `tenantId` cannot be supplied. The caller catches it to show a
* dedicated inline message instead of firing a request that would 400. The
* tenant becomes available once the backend exposes it in the session contract
* (BR-009); until then role mutations degrade gracefully rather than send a
* fabricated value.
*/
export class TenantUnavailableError extends Error {
constructor() {
super('No tenant in session — role mutation unavailable')
this.name = 'TenantUnavailableError'
}
}

/**
* access.policies — per-user role-binding state (Batch 3 RBAC slice).
Expand All @@ -16,6 +32,9 @@ import { listUserRoles, assignRole, revokeRole, type Role } from '../api/roles'
*/

export const usePoliciesStore = defineStore('access.policies', () => {
// accesscore role mutations are tenant-scoped; the tenant comes from the
// session (auth store). See assign / revoke + TenantUnavailableError.
const auth = useAuthStore()
// Generation counter lives inside the factory so it resets when a fresh
// Pinia recreates the store (avoids cross-test coupling, P-F2).
let generation = 0
Expand Down Expand Up @@ -62,9 +81,11 @@ export const usePoliciesStore = defineStore('access.policies', () => {
*/
async function assign(roleId: string): Promise<void> {
if (mutating.value) return
const tenantId = auth.tenantId
if (tenantId === null) throw new TenantUnavailableError()
mutating.value = true
try {
await assignRole({ userId: userId.value, roleId })
await assignRole({ tenantId, userId: userId.value, roleId })
await fetchRoles(userId.value)
} finally {
mutating.value = false
Expand All @@ -78,9 +99,11 @@ export const usePoliciesStore = defineStore('access.policies', () => {
*/
async function revoke(roleId: string): Promise<void> {
if (mutating.value) return
const tenantId = auth.tenantId
if (tenantId === null) throw new TenantUnavailableError()
mutating.value = true
try {
await revokeRole({ userId: userId.value, roleId })
await revokeRole({ tenantId, userId: userId.value, roleId })
await fetchRoles(userId.value)
} finally {
mutating.value = false
Expand Down
22 changes: 21 additions & 1 deletion packages/access/src/views/PoliciesView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { computed } from 'vue'
import { createTestingPinia } from '@pinia/testing'
import { PDP_INJECTION_KEY, type PdpClient } from '@gocell/core'
import type { GoCellRequestError } from '@gocell/request'
import { usePoliciesStore } from '../stores/usePoliciesStore'
import { usePoliciesStore, TenantUnavailableError } from '../stores/usePoliciesStore'
import type { Role } from '../api/roles'
import PoliciesView from './PoliciesView.vue'

Expand Down Expand Up @@ -244,6 +244,26 @@ describe('PoliciesView · mutations', () => {
expect(form.props('assignError')).toBe('access.policies.errors.assignFailed')
})

it('maps TenantUnavailableError to the tenantUnavailable assign-error key', async () => {
const { wrapper, store } = mountWithRoles()
vi.mocked(store.assign).mockRejectedValue(new TenantUnavailableError())
wrapper.findComponent({ name: 'RoleAssignmentForm' }).vm.$emit('assign', 'role-1')
await flushPromises()

const form = wrapper.findComponent({ name: 'RoleAssignmentForm' })
expect(form.props('assignError')).toBe('access.policies.errors.tenantUnavailable')
})

it('maps TenantUnavailableError to the tenantUnavailable revoke-error key', async () => {
const { wrapper, store } = mountWithRoles()
vi.mocked(store.revoke).mockRejectedValue(new TenantUnavailableError())
wrapper.findComponent({ name: 'RoleAssignmentForm' }).vm.$emit('revoke', 'role-1')
await flushPromises()

const form = wrapper.findComponent({ name: 'RoleAssignmentForm' })
expect(form.props('revokeError')).toBe('access.policies.errors.tenantUnavailable')
})

it('assign failure does NOT set revokeError', async () => {
const { wrapper, store } = mountWithRoles()
vi.mocked(store.assign).mockRejectedValue(new Error('assign failed'))
Expand Down
Loading
Loading