| metadata_json |
{"scope":"@juhai","name":& {"scope":"@juhai","name":"client-permission","description":"权限决策远程客户端:把内核 PermissionDecisionPort 实现为对基础设施 permission-decision-api.v1(POST /v1/decisions)的调用;未配置退回内核 fail-closed,传输失败一律拒绝(基础设施开发设计方案 §9 client-*;开发计划 §3A CL-3)","license":"UNLICENSED","development_dependencies":{"@juhai/kernel":"0.24.0","@types/node":"22.19.20","typescript":"5.9.3","vitest":"2.1.9"},"peer_dependencies":{"@juhai/kernel":"0.24.0"},"readme":"# @juhai/client-permission\n\n内核 `PermissionDecisionPort`(`@juhai/kernel@0.24.0`,peer exact)的远程实现:调用基础设施已登记契约\n`permission-decision-api.v1`(`POST \u003cbaseUrl\u003e/v1/decisions`)。设计:基础设施开发设计方案 §9 / 横切 X01;工作项:开发计划 §3A CL-3。\n\n```ts\nimport { createRemotePermissionDecisionPort } from \"@juhai/client-permission\";\n\nconst permission = createRemotePermissionDecisionPort({\n baseUrl: \"https://platform.example/api\", // 含 /api 前缀;路径固定 /v1/decisions\n clientId: \"hr-web\", // 契约 actor.client_id\n token: () =\u003e currentAccessToken(), // Bearer\n actor: (req) =\u003e ({ actor_type: \"human\", authentication_method: \"oidc\" }),\n invalidations: revocationFeed, // DEC-019 / DEC-029 门后由 M3 提供\n});\nregistry.replace({ ...registry.current(), permission });\n```\n\n| 情形 | 决策 |\n|---|---|\n| 未配置(无 `baseUrl` / `clientId`) | 返回内核 fail-closed 端口对象本身(`isFailClosedPort` 为真) |\n| 内核请求 schema 不通过 / 资源类型或动作无法映射为契约形状 | 拒绝 `INVALID_REQUEST`,不打远端 |\n| 无租户上下文 | 拒绝 `NO_TENANT_CONTEXT`,不打远端 |\n| 网络错误 / 完整调用超时(取凭证、传输、读取响应体合计,缺省 2 s)/ 5xx | 拒绝 `REMOTE_UNAVAILABLE` |\n| 401 / 403 / 其它 4xx | 拒绝 `REMOTE_REJECTED` |\n| 200 但不是 `permission-decision-response.v1` 形状 | 拒绝 `INVALID_RESPONSE` |\n| 200 放行且未过期 | `allowed=true`,有效期取服务端截止时间与接收时间 + TTL 的较早值,防止服务端时钟超前延长缓存;只缓存 ttl \u003e 0 的放行(LRU,缺省 1000 条) |\n| 放行到达时已过期(含截止时间相等) | 拒绝 `DECISION_EXPIRED`,本次调用与缓存均不得放行 |\n| TTL = 0 | 本次可放行、`expiresAt=null`,不缓存 |\n| 200 拒绝 | `allowed=false`,`reasonCode` = 契约 `reason_code`(不缓存) |\n| 请求在途收到主体/租户/全局失效,或客户端关闭 | 延迟放行返回 `DECISION_INVALIDATED`,不写回缓存;关闭后不再发起请求 |\n| actor / Scope 映射回调失败 | 拒绝 `REMOTE_UNAVAILABLE`,不会复用旧放行 |\n\n缓存按应用、映射后的主体/会话/认证属性、Scope 版本、资源和动作隔离;显式提供的认证时间也参与缓存键。\n使用结构化键,不用标识符分隔符拼接。失效按准确的租户和主体匹配,同时覆盖已完成和在途请求。\n`authenticated_at` 未提供时生成的请求时间不参与缓存键,避免默认请求时间使缓存失效。\n取凭证回调可接收 `AbortSignal`;忽略信号的依赖也不会阻止客户端在截止时间内返回。\n失效和 `close()` 会取消匹配的在途请求;关闭幂等,超时后迟到的凭证不会再发起远端调用。\n\n映射约定:内核 `resourceType` 须是点号命名空间(如 `hr.employment`),无点号类型可用 `resourceTypePrefix` 补;内核 `action`\n去掉 `\u003cresourceType\u003e.` 前缀后取单段(装配点按 `\u003cresource.type\u003e.\u003caction\u003e` 还原);`trace_id` 无合法 traceId 时生成;\n`request_id` / `correlation_id` 用内核 `requestId`;`causation_id` 用 `factId`。\n\n端口一致性:`runtime/test/port/clients.test.ts` 用 S-6 共用夹具对 stub server(拒绝一切 / 目录式放行 / 不可达)跑本包;\n真实链路:`runtime/apps/api-nestjs/test/platform.http.acceptance.test.ts` 用本包打装载 permission 模块的应用。\n# Authenticated invalidation cursor feed (candidate)\n\n`createRemotePermissionInvalidationSource({ baseUrl, tenantId, actorId, token })` reads the M3 cursor feed over HTTP. Pass it as `invalidations` to `createRemotePermissionDecisionPort`, call `await source.start()`, and close both the decision client and source during shutdown. The token must authenticate the same tenant/actor; it is fetched on every request and never put in the URL.\n\nThe default interval is 1000 ms. The source invalidates tenant caches on initial subscription, disconnect, reconnect, reset and close, preserving its cursor on failures. `stats()` exposes cursor, health and request/failure counts without credentials. `start()` waits for the first response, not all historical pages; pending historical invalidations may reject an in-flight allow. No cached allow should be treated as immune to invalidation.\n\nThe new `/v1/decisions/invalidations` extension is pending contract review. Server activation currently requires explicit development/test configuration; it is not enabled in production. The HTTP source is a reusable implementation, but production rollout, load and employment Scope enforcement remain unverified.\n\n## 稳定应用身份与 Owner 联合接入\n\n机器调用的 `clientId` 是 OAuth 凭证标识(例如 `srv_…`);请求上下文的 `appId` 使用 IdP 登记的稳定 Application ID。服务端从已经验签并经过内省的 `applicationId` 匹配 Grant,不接受请求体声明应用身份。普通 OIDC 的旧 `clientId` 授权匹配保持兼容。机器令牌主体、凭证 ID、主体类型仍须匹配。\n\nledger 的放行响应 `policy_version=permission-ledger:\u003csha256\u003e`,摘要来自本次读取的动作、有效 Grant 和 tenant/actor 失效版本;未完成策略读取的拒绝仍返回提供方名。它是决策输入证据,不是新业务授权。`scope_version=tenant-only` 明确表示这里只核验 tenant 动作授权,调用方的 Scope 版本不作为服务端已验证证据。\n\n对象访问使用 `createScopedPermissionDecisionPort`,把远程动作授权与已经签名验证的 Scope evaluator 接到同一个执行入口:\n\n```ts\nimport { createRemotePermissionDecisionPort, createScopedPermissionDecisionPort } from \"@juhai/client-permission\";\n\nconst permission = createScopedPermissionDecisionPort({\n permission: createRemotePermissionDecisionPort({\n baseUrl, clientId: serviceClientId, token: currentAccessToken,\n }),\n scope: verifiedScopeClient, // client-scope 的本地 evaluator 或远程 snapshot client\n resolveBinding: async (request, signal) =\u003e owner.loadCurrentBinding(request, signal),\n});\n```\n\nOwner 解析器必须从已认证上下文和权威记录返回绑定,包含 `tenantId/resourceTenantId/actorId/appId/resourceType/resourceId/action`、组织范围、`operation/highRisk/requiredVersion` 及覆盖岗位和资源映射的 `bindingVersion`。禁止直接照搬 HTTP body 的组织 ID、风险标志或绑定版本。实际读写必须使用同一资源 ID;业务写事务还须按 Owner 的锁或版本检查维持这份绑定,否则决策与执行之间仍可能发生变更。\n\n入口先检查绑定与范围,再获取动作决策,然后重读绑定并重新检查 Scope。租户、主体、应用、资源或动作错位、版本变化、过期、需要 live review、依赖超时或异常均拒绝。默认总时限 2 秒,忽略取消信号的依赖也受限。返回的放行 `expiresAt=null` 仅用于本次操作;不要另行缓存联合结果。持久化模块中的非 tenant 动作仍拒绝,联合入口不会把其扩大为 tenant Grant。高风险范围仍需已获批准的在线核验实现。\n\n真实链路回归位于 `runtime/test/e2e/identity-permission-http.test.ts`,使用隔离 SQL、真实 IdP 签名与内省,以及 Ed25519 签名 Scope;测试数据不进入企业授权。\n","repository":{"type":"git","url":"git@gitea.g-hi.com:luoanwu/enterprise-platform.git"}}... |