From ef5ac41b446eaab3681c26bed7d979382bdb4f07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E7=99=BB=E5=B1=B1?= Date: Wed, 25 Mar 2026 18:05:17 +0800 Subject: [PATCH 1/5] feat: add button-level permission control --- BUTTON_PERMISSION_CONTROL_SUMMARY.md | 333 +++++++++++++++++++++++++ app/(dashboard)/access-keys/page.tsx | 39 ++- app/(dashboard)/browser/content.tsx | 7 +- app/(dashboard)/browser/page.tsx | 24 +- app/(dashboard)/events/page.tsx | 2 +- app/(dashboard)/policies/page.tsx | 35 ++- app/(dashboard)/users/page.tsx | 53 ++-- components/buckets/events-tab.tsx | 22 +- components/buckets/info.tsx | 93 +++++-- components/buckets/lifecycle-tab.tsx | 29 ++- components/buckets/new-form.tsx | 25 +- components/buckets/replication-tab.tsx | 28 ++- components/events/columns.tsx | 29 ++- components/object/info.tsx | 109 +++++--- components/object/list.tsx | 84 ++++--- components/object/upload-picker.tsx | 42 +++- components/object/versions.tsx | 54 ++-- components/user/edit-form.tsx | 109 ++++---- hooks/use-permissions.tsx | 13 + lib/console-policy-parser.ts | 158 +++++++----- lib/permission-capabilities.ts | 145 +++++++++++ lib/permission-resources.ts | 36 +++ 22 files changed, 1150 insertions(+), 319 deletions(-) create mode 100644 BUTTON_PERMISSION_CONTROL_SUMMARY.md create mode 100644 lib/permission-capabilities.ts create mode 100644 lib/permission-resources.ts diff --git a/BUTTON_PERMISSION_CONTROL_SUMMARY.md b/BUTTON_PERMISSION_CONTROL_SUMMARY.md new file mode 100644 index 00000000..98a49175 --- /dev/null +++ b/BUTTON_PERMISSION_CONTROL_SUMMARY.md @@ -0,0 +1,333 @@ +# 按钮权限控制改动整理 + +## 1. 变更目标 + +本轮改动的目标,是在现有“菜单级别权限控制”基础上,补齐非 admin 页面内关键按钮的权限控制,并让前端判断逻辑尽量贴近 RustFS 后端当前实际支持的策略语义。 + +当前实现定位如下: + +- 前端按钮控制属于体验层控制,用于隐藏或禁用用户当前无权执行的操作。 +- 后端仍然是最终鉴权方,前端不替代服务端鉴权。 +- 权限判断以 S3/MinIO 风格策略为基础,同时对齐 RustFS 当前已实现的行为。 +- admin-only 菜单页面暂不纳入本轮按钮控制范围,因为页面入口本身已经由菜单权限控制。 +- 暂不处理未纳入权限矩阵的页面。 + +## 2. 本轮实现概览 + +### 2.1 策略解析层补强 + +| 改动点 | 说明 | +| ------------------- | ----------------------------------------------------- | +| `Deny` 优先 | 显式拒绝优先于允许,保持和后端一致 | +| `NotAction` 支持 | 前端能力判断已支持 `NotAction` 语义 | +| `NotResource` 支持 | `ConsoleStatement` 增加 `NotResource`,并参与资源匹配 | +| 通配符处理 | 裸 `*` 视为 `s3:*`,同时兼容 `s3:*`、`admin:*` 等模式 | +| Action alias 归一化 | 对齐 RustFS 当前实际支持的别名和映射 | +| admin 资源校验放宽 | `admin:*` 动作前端忽略资源匹配,贴近后端行为 | +| console scope 推导 | console 菜单 scope 会推导到其隐含的 S3/admin 动作集合 | + +### 2.2 RustFS 对齐要点 + +| 语义 | 当前前端对齐方式 | +| ------------------------- | -------------------------------------------------------------------------------------------- | +| `*` | 视为 `s3:*` | +| `DeleteBucketTagging` | 归并到 `s3:PutBucketTagging` | +| `DeleteBucketReplication` | 归并到 `s3:PutReplicationConfiguration` | +| `DeleteBucketLifecycle` | 归并到 `s3:PutBucketLifecycle` | +| `DeleteBucketEncryption` | 通过桶加密编辑能力统一处理 | +| 生命周期旧别名 | `GetLifecycleConfiguration` / `PutLifecycleConfiguration` 归一到 RustFS 当前桶生命周期动作名 | +| 对象版本读取 | 目前以前端 `GetObject` 近似映射 `objects.version.view` | + +### 2.3 新增能力层 + +新增两个基础文件: + +- `lib/permission-capabilities.ts` +- `lib/permission-resources.ts` + +能力层职责: + +- 将前端“页面按钮动作”抽象为 `ConsoleCapability` +- 将 capability 映射到后端真实 action 集合 +- 按资源粒度构造 S3 ARN +- 通过 `hasConsoleCapability(...)` 做统一判断 + +### 2.4 `usePermissions` 扩展 + +`hooks/use-permissions.tsx` 新增: + +- `canCapability(capability, context?)` + +它的用途是: + +- 页面只关心“这个按钮能不能显示/能不能点” +- 能力与 action/资源的复杂映射统一收敛到 lib 层 + +## 3. 新增与修改文件范围 + +### 3.1 新增文件 + +| 文件 | 作用 | +| -------------------------------- | ----------------------------------------- | +| `lib/permission-capabilities.ts` | 定义 capability 与 action/resource 的映射 | +| `lib/permission-resources.ts` | 构造 bucket/object/prefix 对应的 S3 ARN | + +### 3.2 修改文件 + +| 文件 | 作用 | +| ---------------------------------------- | ------------------------------------------------------------ | +| `lib/console-policy-parser.ts` | 补齐 `NotResource`、alias、wildcard、admin 资源规则 | +| `hooks/use-permissions.tsx` | 暴露 `canCapability` | +| `app/(dashboard)/browser/page.tsx` | 桶列表页按钮权限控制 | +| `app/(dashboard)/browser/content.tsx` | 对象页上传能力下发 | +| `components/object/list.tsx` | 对象列表按钮权限控制 | +| `components/object/upload-picker.tsx` | 上传弹窗内部按钮权限控制 | +| `components/object/info.tsx` | 对象详情抽屉按钮权限控制 | +| `components/object/versions.tsx` | 对象版本列表按钮权限控制 | +| `components/buckets/info.tsx` | 桶详情页按钮与提交兜底 | +| `components/buckets/new-form.tsx` | 新建桶表单按钮与开关控制 | +| `components/buckets/lifecycle-tab.tsx` | 生命周期规则按钮控制 | +| `components/buckets/replication-tab.tsx` | 复制规则按钮控制 | +| `components/buckets/events-tab.tsx` | 桶事件订阅按钮控制 | +| `components/events/columns.tsx` | 事件列表删除按钮可配置 | +| `app/(dashboard)/access-keys/page.tsx` | Access Keys 页面按钮控制 | +| `app/(dashboard)/users/page.tsx` | Users 页面按钮控制 | +| `components/user/edit-form.tsx` | 用户编辑弹窗内部权限收敛 | +| `app/(dashboard)/policies/page.tsx` | Policies 页面按钮控制 | +| `app/(dashboard)/events/page.tsx` | 适配 `getEventsColumns` 新签名,尚未真正接入 capability 控制 | + +## 4. 能力与动作映射 + +### 4.1 Bucket 能力 + +| Capability | 后端 Action | 资源粒度 | +| ------------------------- | ----------------------------------------------- | ---------- | +| `bucket.create` | `s3:CreateBucket` | 全部桶 | +| `bucket.delete` | `s3:DeleteBucket` | 桶 | +| `bucket.policy.put` | `s3:PutBucketPolicy` | 桶 | +| `bucket.policy.delete` | `s3:DeleteBucketPolicy` | 桶 | +| `bucket.policy.edit` | `s3:PutBucketPolicy` 或 `s3:DeleteBucketPolicy` | 桶 | +| `bucket.encryption.edit` | `s3:PutBucketEncryption` | 桶 | +| `bucket.tag.edit` | `s3:PutBucketTagging` | 桶 | +| `bucket.versioning.edit` | `s3:PutBucketVersioning` | 桶 | +| `bucket.objectLock.edit` | `s3:PutBucketObjectLockConfiguration` | 桶 | +| `bucket.quota.edit` | `admin:SetBucketQuota` | 无资源匹配 | +| `bucket.lifecycle.edit` | `s3:PutBucketLifecycle` | 桶 | +| `bucket.replication.edit` | `s3:PutReplicationConfiguration` | 桶 | +| `bucket.events.edit` | `s3:PutBucketNotification` | 桶 | + +### 4.2 Object 能力 + +| Capability | 后端 Action | 资源粒度 | +| ------------------------ | ----------------------- | -------- | +| `objects.upload` | `s3:PutObject` | 对象前缀 | +| `objects.view` | `s3:GetObject` | 对象 | +| `objects.preview` | `s3:GetObject` | 对象 | +| `objects.download` | `s3:GetObject` | 对象 | +| `objects.delete` | `s3:DeleteObject` | 对象 | +| `objects.bulkDelete` | `s3:DeleteObject` | 对象前缀 | +| `objects.tag.view` | `s3:GetObjectTagging` | 对象 | +| `objects.tag.edit` | `s3:PutObjectTagging` | 对象 | +| `objects.legalHold.edit` | `s3:PutObjectLegalHold` | 对象 | +| `objects.retention.edit` | `s3:PutObjectRetention` | 对象 | +| `objects.version.view` | `s3:GetObject` | 对象 | +| `objects.share` | `s3:GetObject` | 对象 | + +### 4.3 IAM/Policy 能力 + +| Capability | 后端 Action | 资源粒度 | +| ----------------------- | ------------------------------------------------------------------------------------- | ---------- | +| `accessKeys.create` | `admin:CreateServiceAccount` | 无资源匹配 | +| `accessKeys.edit` | `admin:UpdateServiceAccount` | 无资源匹配 | +| `accessKeys.delete` | `admin:RemoveServiceAccount` | 无资源匹配 | +| `accessKeys.bulkDelete` | `admin:RemoveServiceAccount` | 无资源匹配 | +| `users.create` | `admin:CreateUser` | 无资源匹配 | +| `users.edit` | `admin:GetUser` 且 `admin:CreateUser` / `admin:EnableUser` / `admin:DisableUser` 任一 | 无资源匹配 | +| `users.delete` | `admin:DeleteUser` | 无资源匹配 | +| `users.bulkDelete` | `admin:DeleteUser` | 无资源匹配 | +| `users.assignGroups` | `admin:AddUserToGroup` 或 `admin:RemoveUserFromGroup` | 无资源匹配 | +| `users.policy.edit` | `admin:AttachUserOrGroupPolicy` 或 `admin:UpdatePolicyAssociation` | 无资源匹配 | +| `policies.create` | `admin:CreatePolicy` | 无资源匹配 | +| `policies.edit` | `admin:CreatePolicy` | 无资源匹配 | +| `policies.delete` | `admin:DeletePolicy` | 无资源匹配 | + +## 5. 按钮权限矩阵 + +### 5.1 页面入口与列表按钮 + +| 页面/组件 | 按钮 | Capability | 后端 Action | 资源上下文 | 当前控制方式 | +| ---------------------------------------- | ------------------------ | -------------------------------------------------------------- | ---------------------------------------------------- | ----------------- | -------------------------- | +| `/browser` 桶列表 | `Create Bucket` | `bucket.create` | `s3:CreateBucket` | 全部桶 | 无权限时隐藏 | +| `/browser` 桶列表 | 行内 `Delete` | `bucket.delete` | `s3:DeleteBucket` | 当前桶 | 无权限时隐藏 | +| `/browser?bucket=...` 对象列表 | `Upload File/Folder` | `objects.upload` | `s3:PutObject` | 当前桶 + 当前前缀 | 无权限时隐藏 | +| `/browser?bucket=...` 对象列表 | `Delete Selected` | `objects.bulkDelete` | `s3:DeleteObject` | 当前桶 + 当前前缀 | 无权限时隐藏 | +| `/browser?bucket=...` 对象列表 | `Download`(批量) | `objects.download` | `s3:GetObject` | 当前桶 + 当前前缀 | 无权限时隐藏 | +| `/browser?bucket=...` 对象列表 | 行内 `Preview` | `objects.preview` | `s3:GetObject` | 当前对象 | 无权限时隐藏 | +| `/browser?bucket=...` 对象列表 | 行内 `Download` | `objects.download` | `s3:GetObject` | 当前对象 | 无权限时隐藏 | +| `/browser?bucket=...` 对象列表 | 行内 `Delete` | `objects.delete` | `s3:DeleteObject` | 当前对象/前缀 | 无权限时隐藏 | +| `components/buckets/lifecycle-tab.tsx` | `Add Lifecycle Rule` | `bucket.lifecycle.edit` | `s3:PutBucketLifecycle` | 当前桶 | 无权限时隐藏 | +| `components/buckets/lifecycle-tab.tsx` | 行内 `Delete` | `bucket.lifecycle.edit` | `s3:PutBucketLifecycle` | 当前桶 | 无权限时隐藏 | +| `components/buckets/replication-tab.tsx` | `Add Replication Rule` | `bucket.replication.edit` | `s3:PutReplicationConfiguration` | 当前桶 | 无权限时隐藏 | +| `components/buckets/replication-tab.tsx` | 行内 `Delete` | `bucket.replication.edit` | `s3:PutReplicationConfiguration` | 当前桶 | 无权限时隐藏 | +| `components/buckets/events-tab.tsx` | `Add Event Subscription` | `bucket.events.edit` | `s3:PutBucketNotification` | 当前桶 | 无权限时隐藏 | +| `components/buckets/events-tab.tsx` | 行内 `Delete` | `bucket.events.edit` | `s3:PutBucketNotification` | 当前桶 | 无权限时隐藏 | +| `/access-keys` | `Add Access Key` | `accessKeys.create` | `admin:CreateServiceAccount` | 无 | 无权限时隐藏 | +| `/access-keys` | 行内 `Edit` | `accessKeys.edit` | `admin:UpdateServiceAccount` | 无 | 无权限时隐藏 | +| `/access-keys` | 行内 `Delete` | `accessKeys.delete` | `admin:RemoveServiceAccount` | 无 | 无权限时隐藏 | +| `/access-keys` | `Delete Selected` | `accessKeys.bulkDelete` | `admin:RemoveServiceAccount` | 无 | 仅在有权限且有选中项时显示 | +| `/users` | `Add User` | `users.create` | `admin:CreateUser` | 无 | 无权限时隐藏 | +| `/users` | 行内 `Edit` | `users.edit` / `users.assignGroups` / `users.policy.edit` 任一 | 组合能力 | 无 | 任一编辑相关能力满足即显示 | +| `/users` | 行内 `Delete` | `users.delete` | `admin:DeleteUser` | 无 | 无权限时隐藏 | +| `/users` | `Delete Selected` | `users.bulkDelete` | `admin:DeleteUser` | 无 | 无权限时禁用 | +| `/users` | `Add to Group` | `users.assignGroups` | `admin:AddUserToGroup` / `admin:RemoveUserFromGroup` | 无 | 无权限时禁用 | +| `/policies` | `New Policy` | `policies.create` | `admin:CreatePolicy` | 无 | 无权限时隐藏 | +| `/policies` | 行内 `Edit` | `policies.edit` | `admin:CreatePolicy` | 无 | 无权限时隐藏 | +| `/policies` | 行内 `Delete` | `policies.delete` | `admin:DeletePolicy` | 无 | 无权限时隐藏 | + +### 5.2 详情抽屉、弹窗、表单内部按钮 + +| 页面/组件 | 按钮 | Capability | 后端 Action | 资源上下文 | 当前控制方式 | +| ------------------------------------- | ------------------------- | --------------------------------------------------------- | ---------------------------------------------- | ------------- | ------------------------------------------------- | +| `components/object/upload-picker.tsx` | `Select File` | `objects.upload` | `s3:PutObject` | 当前桶 + 前缀 | 无权限时禁用 | +| `components/object/upload-picker.tsx` | `Select Folder` | `objects.upload` | `s3:PutObject` | 当前桶 + 前缀 | 无权限时禁用 | +| `components/object/upload-picker.tsx` | `Clear All` | `objects.upload` | `s3:PutObject` | 当前桶 + 前缀 | 无权限时禁用 | +| `components/object/upload-picker.tsx` | `Start Upload` | `objects.upload` | `s3:PutObject` | 当前桶 + 前缀 | 无权限时禁用 | +| `components/object/info.tsx` | `Download` | `objects.download` | `s3:GetObject` | 当前对象 | 无权限时隐藏 | +| `components/object/info.tsx` | `Preview` | `objects.preview` | `s3:GetObject` | 当前对象 | 无权限时隐藏 | +| `components/object/info.tsx` | `Set Tags` | `objects.tag.view` 或 `objects.tag.edit` | `s3:GetObjectTagging` / `s3:PutObjectTagging` | 当前对象 | 无查看/编辑权限时隐藏 | +| `components/object/info.tsx` | `Versions` | `objects.version.view` | `s3:GetObject` | 当前对象 | 无权限时隐藏 | +| `components/object/info.tsx` | `Retention` | `objects.retention.edit` | `s3:PutObjectRetention` | 当前对象 | 无权限时隐藏 | +| `components/object/info.tsx` | `Generate URL` | `objects.share` | `s3:GetObject` | 当前对象 | 无权限时禁用 | +| `components/object/info.tsx` | 标签删除图标 | `objects.tag.edit` | `s3:PutObjectTagging` | 当前对象 | 无权限时隐藏 | +| `components/object/info.tsx` | 标签弹窗 `Add` | `objects.tag.edit` | `s3:PutObjectTagging` | 当前对象 | 无权限时禁用,提交函数再次兜底 | +| `components/object/info.tsx` | 保留期弹窗 `Reset` | `objects.retention.edit` | `s3:PutObjectRetention` | 当前对象 | 无权限时禁用,提交函数再次兜底 | +| `components/object/info.tsx` | 保留期弹窗 `Confirm` | `objects.retention.edit` | `s3:PutObjectRetention` | 当前对象 | 无权限时禁用,提交函数再次兜底 | +| `components/object/versions.tsx` | 行内 `Preview` | `objects.version.view` | `s3:GetObject` | 当前对象 | 无权限时隐藏 | +| `components/object/versions.tsx` | 行内 `Download` | `objects.download` | `s3:GetObject` | 当前对象 | 无权限时隐藏 | +| `components/object/versions.tsx` | 行内 `Delete` | `objects.delete` | `s3:DeleteObject` | 当前对象 | 无权限时隐藏 | +| `components/buckets/info.tsx` | `Edit`(Access Policy) | `bucket.policy.put` / `bucket.policy.delete` | `s3:PutBucketPolicy` / `s3:DeleteBucketPolicy` | 当前桶 | 有任一策略编辑能力时显示 | +| `components/buckets/info.tsx` | `Edit`(Encryption) | `bucket.encryption.edit` | `s3:PutBucketEncryption` | 当前桶 | 无权限时隐藏 | +| `components/buckets/info.tsx` | `Add`(Tag) | `bucket.tag.edit` | `s3:PutBucketTagging` | 当前桶 | 无权限时隐藏 | +| `components/buckets/info.tsx` | 标签点击编辑 | `bucket.tag.edit` | `s3:PutBucketTagging` | 当前桶 | 无权限时禁用 | +| `components/buckets/info.tsx` | 标签删除图标 | `bucket.tag.edit` | `s3:PutBucketTagging` | 当前桶 | 无权限时禁用 | +| `components/buckets/info.tsx` | `Edit`(Bucket Quota) | `bucket.quota.edit` | `admin:SetBucketQuota` | 无 | 无权限时隐藏 | +| `components/buckets/info.tsx` | `Edit`(Retention) | `bucket.objectLock.edit` | `s3:PutBucketObjectLockConfiguration` | 当前桶 | 无权限时隐藏 | +| `components/buckets/info.tsx` | Policy 弹窗 `Confirm` | `bucket.policy.put` / `bucket.policy.delete` | 对应策略动作 | 当前桶 | 提交函数按 policy 类型再次校验 | +| `components/buckets/info.tsx` | Encryption 弹窗 `Confirm` | `bucket.encryption.edit` | `s3:PutBucketEncryption` | 当前桶 | 通过入口控制进入 | +| `components/buckets/info.tsx` | Tag 弹窗 `Confirm` | `bucket.tag.edit` | `s3:PutBucketTagging` | 当前桶 | 提交函数再次兜底 | +| `components/buckets/info.tsx` | Quota 弹窗 `Confirm` | `bucket.quota.edit` | `admin:SetBucketQuota` | 无 | 提交函数再次兜底 | +| `components/buckets/info.tsx` | Retention 弹窗 `Confirm` | `bucket.objectLock.edit` | `s3:PutBucketObjectLockConfiguration` | 当前桶 | 提交函数再次兜底 | +| `components/buckets/new-form.tsx` | `Create` | `bucket.create` | `s3:CreateBucket` | 全部桶 | 无权限时禁用 | +| `components/user/edit-form.tsx` | `Submit` | `users.edit` / `users.assignGroups` / `users.policy.edit` | 组合能力 | 无 | 没有任何可编辑 tab 时禁用;提交仅发送允许的子操作 | + +### 5.3 非按钮交互控件 + +| 页面/组件 | 交互控件 | Capability | 当前控制方式 | +| --------------------------------- | -------------------------------------- | ------------------------ | ---------------------- | +| `components/object/info.tsx` | `Legal Hold` 开关 | `objects.legalHold.edit` | 无权限时禁用 | +| `components/buckets/info.tsx` | `Version Control` 开关 | `bucket.versioning.edit` | 无权限时禁用 | +| `components/buckets/new-form.tsx` | `Version` 开关 | `bucket.versioning.edit` | 无权限时禁用 | +| `components/buckets/new-form.tsx` | `Object Lock` 开关 | `bucket.objectLock.edit` | 无权限时禁用 | +| `components/buckets/new-form.tsx` | `Bucket Quota` 开关 | `bucket.quota.edit` | 无权限时禁用 | +| `components/user/edit-form.tsx` | `Account` / `Groups` / `Policies` Tabs | 对应用户能力 | 无对应权限时不渲染 tab | +| `components/user/edit-form.tsx` | 用户状态开关 | `users.edit` | 无权限时禁用 | + +## 6. 当前边界与已知待补位项 + +### 6.1 本轮明确不纳入 + +| 范围 | 说明 | +| -------------------- | ------------------------------------------ | +| admin-only 页面 | 菜单入口已受控,本轮不继续做页面内按钮矩阵 | +| 未纳入权限矩阵的页面 | 按当前决策先忽略,不在本轮补齐 | + +### 6.2 当前仍需后续完善 + +| 范围 | 当前状态 | 后续建议 | +| -------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------- | +| `/events` 页面 | 只适配了 `getEventsColumns` 的 `canDelete` 参数,当前仍传 `true` | 后续补上 `bucket.events.edit` 的真实 capability 判断 | +| `components/access-keys/new-item.tsx` | 页面入口已受控,弹窗内部未单独做 capability 下沉 | 若存在绕过入口的调用路径,再补内部兜底 | +| `components/access-keys/edit-item.tsx` | 同上 | 同上 | +| `components/policies/form.tsx` | 页面入口已受控,表单内部未单独补 capability | 后续可补提交兜底 | +| `components/user/new-form.tsx` | 页面入口已受控,弹窗内部未单独补 capability | 后续可补提交兜底 | +| `components/lifecycle/new-form.tsx` | 入口按钮已受控,表单内部未单独补 capability | 后续可补提交兜底 | +| `components/replication/new-form.tsx` | 同上 | 同上 | +| `components/events/new-form.tsx` | 同上 | 同上 | +| 对象版本能力精度 | `objects.version.view` 目前用 `s3:GetObject` 近似 | 后续若后端暴露更细粒度版本动作,可继续细化 | +| Governance bypass | `s3:BypassGovernanceRetention` 尚未单独建 capability | 如果前端后续暴露该操作,再单独建模 | + +## 7. README 可复用内容 + +下面这段可以直接作为 README 中“权限控制”或“按钮级权限控制”章节的基础说明: + +```md +## Button-Level Permission Control + +The console now supports capability-based button control on top of menu-level access control. + +- Policies are parsed with S3/MinIO-compatible semantics and aligned with current RustFS backend behavior. +- Frontend capability checks support `Deny` precedence, `NotAction`, `NotResource`, wildcard matching, and RustFS action aliases. +- A dedicated capability layer maps UI actions to backend permissions and resource scopes. +- Bucket-, object-, IAM-, and policy-related operations on non-admin pages are now gated by capability checks. +- Frontend button control is an experience-layer safeguard only; backend authorization remains the source of truth. +``` + +如果 README 需要中文版本,可以使用: + +```md +## 按钮级权限控制 + +控制台目前已在菜单权限控制基础上,补充了非 admin 页面中的按钮级权限控制。 + +- 策略解析参考 S3 / MinIO 语义,并对齐当前 RustFS 后端行为。 +- 前端已支持 `Deny` 优先、`NotAction`、`NotResource`、通配符匹配以及 RustFS 动作别名归一化。 +- 新增 capability 能力层,将页面按钮动作统一映射为后端权限与资源范围。 +- 当前已覆盖桶、对象、Access Key、用户、策略、生命周期、复制、桶事件等主要非 admin 页面操作。 +- 前端按钮控制仅用于体验层约束,最终权限校验仍以后端为准。 +``` + +## 8. PR 描述草稿 + +下面内容可以直接作为 PR 描述初稿: + +```md +## Summary + +This PR adds button-level permission control on top of the existing menu-level access control. + +It introduces a capability layer for mapping UI actions to backend permissions, and aligns frontend policy parsing with current RustFS behavior. + +## What Changed + +- added `permission-capabilities` and `permission-resources` to map UI capabilities to backend actions and resource scopes +- extended `console-policy-parser` to support `NotResource`, RustFS action aliases, wildcard normalization, and admin action matching behavior +- exposed `canCapability(capability, context?)` from `usePermissions` +- gated non-admin page actions for: + - bucket creation and deletion + - bucket policy, encryption, tagging, versioning, quota, retention, lifecycle, replication, and bucket events + - object upload, preview, download, delete, bulk delete, tags, legal hold, retention, versions, and share URL generation + - access key create/edit/delete/bulk delete + - user create/edit/delete/bulk delete/group assignment/policy editing + - policy create/edit/delete +- added internal submit-path guards for several bucket/object/user edit flows to avoid relying only on button visibility + +## Notes + +- this is frontend UX gating only; backend authorization remains authoritative +- admin-only pages are intentionally out of scope for this round +- pages outside the current permission matrix are intentionally ignored for now +- `/events` page is only partially adapted and still needs real capability-based gating in a follow-up + +## Validation + +- aligned permission semantics against current RustFS policy behavior +- manually reviewed capability coverage across bucket, object, IAM, and policy pages +``` + +## 9. 补充建议 + +如果后续要继续推进“全按钮覆盖”,建议按下面顺序继续做: + +1. 先补 `/events` 页面真实 capability 控制,避免桶事件页面存在前后不一致。 +2. 再把 `new-form` / `edit-item` / `form` 这类弹窗内部提交逻辑全部下沉 capability 兜底。 +3. 最后再扩展到当前未纳入权限矩阵的页面和 admin-only 页面中的细粒度按钮。 diff --git a/app/(dashboard)/access-keys/page.tsx b/app/(dashboard)/access-keys/page.tsx index c1c98fcb..5641825b 100644 --- a/app/(dashboard)/access-keys/page.tsx +++ b/app/(dashboard)/access-keys/page.tsx @@ -12,6 +12,7 @@ import { PageHeader } from "@/components/page-header" import { DataTable } from "@/components/data-table/data-table" import { DataTablePagination } from "@/components/data-table/data-table-pagination" import { useDataTable } from "@/hooks/use-data-table" +import { usePermissions } from "@/hooks/use-permissions" import { AccessKeysNewItem } from "@/components/access-keys/new-item" import { AccessKeysEditItem } from "@/components/access-keys/edit-item" import { UserNotice } from "@/components/user/notice" @@ -34,6 +35,7 @@ export default function AccessKeysPage() { const dialog = useDialog() const message = useMessage() const { listUserServiceAccounts, deleteServiceAccount } = useAccessKeys() + const { canCapability } = usePermissions() const [data, setData] = useState([]) const [loading, setLoading] = useState(false) @@ -47,6 +49,11 @@ export default function AccessKeysPage() { url?: string } | null>(null) + const canCreateAccessKey = canCapability("accessKeys.create") + const canEditAccessKey = canCapability("accessKeys.edit") + const canDeleteAccessKey = canCapability("accessKeys.delete") + const canBulkDeleteAccessKeys = canCapability("accessKeys.bulkDelete") + const listUserAccounts = async () => { setLoading(true) try { @@ -112,14 +119,18 @@ export default function AccessKeysPage() { meta: { width: 200 }, cell: ({ row }) => (
- - + {canEditAccessKey ? ( + + ) : null} + {canDeleteAccessKey ? ( + + ) : null}
), }, @@ -207,16 +218,18 @@ export default function AccessKeysPage() { clearable className="max-w-xs" /> - {selectedKeys.length > 0 && ( + {selectedKeys.length > 0 && canBulkDeleteAccessKeys && ( )} - + {canCreateAccessKey ? ( + + ) : null} } > diff --git a/app/(dashboard)/browser/content.tsx b/app/(dashboard)/browser/content.tsx index 90fadcd0..053557c9 100644 --- a/app/(dashboard)/browser/content.tsx +++ b/app/(dashboard)/browser/content.tsx @@ -16,6 +16,7 @@ import { buildBucketPath } from "@/lib/bucket-path" import { useTasks } from "@/contexts/task-context" import { ObjectPreviewModal } from "@/components/object/preview-modal" import { useObject } from "@/hooks/use-object" +import { usePermissions } from "@/hooks/use-permissions" interface BrowserContentProps { bucketName: string @@ -24,11 +25,12 @@ interface BrowserContentProps { previewKey?: string } -export function BrowserContent({ bucketName, keyPath = "", preview = false, previewKey = "" }: BrowserContentProps) { +export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps) { const { t } = useTranslation() const router = useRouter() const searchParams = useSearchParams() const message = useMessage() + const { canCapability } = usePermissions() const { headBucket } = useBucket() const isObjectList = keyPath.endsWith("/") || keyPath === "" @@ -41,6 +43,7 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev const [showPreview, setShowPreview] = React.useState(false) const [previewObject, setPreviewObject] = React.useState | null>(null) const objectApi = useObject(bucketName) + const canUploadObjects = canCapability("objects.upload", { bucket: bucketName, prefix }) React.useEffect(() => { if (!bucketName) return @@ -152,6 +155,7 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev path={prefix} onOpenInfo={handleOpenInfo} onUploadClick={() => setUploadPickerOpen(true)} + canUpload={canUploadObjects} onRefresh={handleRefresh} refreshTrigger={refreshTrigger} onPreview={handleOpenPreview} @@ -175,6 +179,7 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev onShowChange={setUploadPickerOpen} bucketName={bucketName} prefix={prefix} + canUpload={canUploadObjects} /> setShowPreview(show)} object={previewObject} /> diff --git a/app/(dashboard)/browser/page.tsx b/app/(dashboard)/browser/page.tsx index b180072d..cd1a52cb 100644 --- a/app/(dashboard)/browser/page.tsx +++ b/app/(dashboard)/browser/page.tsx @@ -16,6 +16,7 @@ import { Spinner } from "@/components/ui/spinner" import { useBucket } from "@/hooks/use-bucket" import { useObject } from "@/hooks/use-object" import { useSystem } from "@/hooks/use-system" +import { usePermissions } from "@/hooks/use-permissions" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import { niceBytes } from "@/lib/functions" @@ -39,6 +40,7 @@ function BrowserBucketsPage() { const router = useRouter() const message = useMessage() const dialog = useDialog() + const { canCapability } = usePermissions() const { listBuckets, deleteBucket, getBucketPolicyStatus } = useBucket() const { getDataUsageInfo } = useSystem() @@ -50,6 +52,8 @@ function BrowserBucketsPage() { const [policyLoading, setPolicyLoading] = useState(false) const fetchIdRef = useRef(0) + const canCreateBucket = canCapability("bucket.create") + const loadBucketUsage = useCallback( async (fetchId: number, bucketNames: string[]) => { if (bucketNames.length === 0) { @@ -270,10 +274,12 @@ function BrowserBucketsPage() { {t("Settings")} - + {canCapability("bucket.delete", { bucket: row.original.Name }) ? ( + + ) : null} ), }) @@ -334,10 +340,12 @@ function BrowserBucketsPage() { clearable className="max-w-sm" /> - + {canCreateBucket ? ( + + ) : null} - + {canEditPolicy ? ( + + ) : null} + {canDeletePolicy ? ( + + ) : null} ), }, @@ -140,10 +149,12 @@ export default function PoliciesPage() { clearable className="max-w-sm" /> - + {canCreatePolicy ? ( + + ) : null} } > diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx index 628a9010..8aca7eb9 100644 --- a/app/(dashboard)/users/page.tsx +++ b/app/(dashboard)/users/page.tsx @@ -13,6 +13,7 @@ import { PageHeader } from "@/components/page-header" import { DataTable } from "@/components/data-table/data-table" import { DataTablePagination } from "@/components/data-table/data-table-pagination" import { useDataTable } from "@/hooks/use-data-table" +import { usePermissions } from "@/hooks/use-permissions" import { UserNewForm } from "@/components/user/new-form" import { UserEditForm } from "@/components/user/edit-form" import { useUsers } from "@/hooks/use-users" @@ -49,6 +50,14 @@ export default function UsersPage() { const message = useMessage() const dialog = useDialog() const { listUsers, deleteUser } = useUsers() + const { canCapability } = usePermissions() + + const canCreateUser = canCapability("users.create") + const canEditUser = + canCapability("users.edit") || canCapability("users.assignGroups") || canCapability("users.policy.edit") + const canDeleteUser = canCapability("users.delete") + const canBulkDeleteUsers = canCapability("users.bulkDelete") + const canAssignGroups = canCapability("users.assignGroups") const [data, setData] = useState([]) const [loading, setLoading] = useState(false) @@ -121,14 +130,18 @@ export default function UsersPage() { meta: { width: 200 }, cell: ({ row }) => (
- - + {canEditUser ? ( + + ) : null} + {canDeleteUser ? ( + + ) : null}
), }, @@ -210,18 +223,30 @@ export default function UsersPage() { clearable className="max-w-xs" /> - - - + {canCreateUser ? ( + + ) : null} } > diff --git a/components/buckets/events-tab.tsx b/components/buckets/events-tab.tsx index 6404b725..6f5a2c1a 100644 --- a/components/buckets/events-tab.tsx +++ b/components/buckets/events-tab.tsx @@ -9,6 +9,7 @@ import { useDataTable } from "@/hooks/use-data-table" import { EventsNewForm } from "@/components/events/new-form" import { getEventsColumns } from "@/components/events/columns" import { useBucket } from "@/hooks/use-bucket" +import { usePermissions } from "@/hooks/use-permissions" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import type { NotificationItem } from "@/lib/events" @@ -21,7 +22,10 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) { const { t } = useTranslation() const message = useMessage() const dialog = useDialog() + const { canCapability } = usePermissions() const { listBucketNotifications, putBucketNotifications } = useBucket() + const eventsContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName]) + const canEditEvents = canCapability("bucket.events.edit", eventsContext) const [data, setData] = React.useState([]) const [loading, setLoading] = React.useState(false) @@ -91,6 +95,7 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) { const handleRowDelete = React.useCallback( async (row: NotificationItem) => { + if (!canEditEvents) return const confirmed = await new Promise((resolve) => { dialog.warning({ title: t("Confirm Delete"), @@ -136,10 +141,13 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) { setLoading(false) } }, - [bucketName, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t], + [bucketName, canEditEvents, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t], ) - const columns = React.useMemo(() => getEventsColumns(t, handleRowDelete), [t, handleRowDelete]) + const columns = React.useMemo( + () => getEventsColumns(t, handleRowDelete, canEditEvents), + [canEditEvents, t, handleRowDelete], + ) const { table } = useDataTable({ data, @@ -152,10 +160,12 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {

{t("Events")}

- + {canEditEvents ? ( + + ) : null} + {canEditBucketPolicy ? ( + + ) : null} @@ -466,10 +498,12 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { {encryptionLabel}
- + {canEditEncryption ? ( + + ) : null} @@ -479,10 +513,12 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { {t("Tag")} - + {canEditTags ? ( + + ) : null} @@ -493,10 +529,21 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { key={`${tag.Key}-${index}`} className="flex items-center gap-2 rounded-full border bg-muted/40 px-3 py-1 text-xs" > - -
@@ -525,7 +572,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { @@ -542,9 +589,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { - + {canEditQuota ? ( + + ) : null} {quotaInfo?.quota ? ( @@ -575,9 +624,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) { - + {canEditObjectLock ? ( + + ) : null} diff --git a/components/buckets/lifecycle-tab.tsx b/components/buckets/lifecycle-tab.tsx index 54c8a54b..6ccbcca1 100644 --- a/components/buckets/lifecycle-tab.tsx +++ b/components/buckets/lifecycle-tab.tsx @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge" import { DataTable } from "@/components/data-table/data-table" import { useDataTable } from "@/hooks/use-data-table" import { useBucket } from "@/hooks/use-bucket" +import { usePermissions } from "@/hooks/use-permissions" import { LifecycleNewForm } from "@/components/lifecycle/new-form" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" @@ -43,7 +44,10 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) { const { t } = useTranslation() const message = useMessage() const dialog = useDialog() + const { canCapability } = usePermissions() const { getBucketLifecycleConfiguration, deleteBucketLifecycle, putBucketLifecycleConfiguration } = useBucket() + const lifecycleContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName]) + const canEditLifecycle = canCapability("bucket.lifecycle.edit", lifecycleContext) const [data, setData] = React.useState([]) const [loading, setLoading] = React.useState(false) @@ -70,6 +74,7 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) { const handleRowDelete = React.useCallback( async (row: LifecycleRule) => { + if (!canEditLifecycle) return const remaining = data.filter((item) => item.ID !== row.ID) try { @@ -86,7 +91,7 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) { message.error((error as Error).message || t("Delete Failed")) } }, - [data, deleteBucketLifecycle, bucketName, putBucketLifecycleConfiguration, message, t, loadData], + [canEditLifecycle, data, deleteBucketLifecycle, bucketName, putBucketLifecycleConfiguration, message, t, loadData], ) const confirmDelete = React.useCallback( @@ -159,15 +164,17 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) { enableSorting: false, cell: ({ row }) => (
- + {canEditLifecycle ? ( + + ) : null}
), }, ], - [t, confirmDelete], + [canEditLifecycle, t, confirmDelete], ) const { table } = useDataTable({ @@ -181,10 +188,12 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) {

{t("Lifecycle")}

- + {canEditLifecycle ? ( + + ) : null} - + {canEditReplication ? ( + + ) : null}
), }, ], - [t, confirmDelete], + [canEditReplication, t, confirmDelete], ) const { table } = useDataTable({ @@ -170,10 +178,12 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)

{t("Bucket Replication")}

- + {canEditReplication ? ( + + ) : null} + {canDelete ? ( + + ) : null}
), }, diff --git a/components/object/info.tsx b/components/object/info.tsx index a54fbb34..0783d599 100644 --- a/components/object/info.tsx +++ b/components/object/info.tsx @@ -16,6 +16,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { CopyInput } from "@/components/copy-input" import { useObject } from "@/hooks/use-object" +import { usePermissions } from "@/hooks/use-permissions" import { useMessage } from "@/lib/feedback/message" import { useDialog } from "@/lib/feedback/dialog" import { exportFile } from "@/lib/export-file" @@ -42,6 +43,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie const message = useMessage() const dialog = useDialog() const objectApi = useObject(bucketName) + const { canCapability } = usePermissions() const client = useS3() const [object, setObject] = React.useState | null>(null) @@ -66,6 +68,22 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie const [isExpirationValid, setIsExpirationValid] = React.useState(false) const [isGeneratingUrl, setIsGeneratingUrl] = React.useState(false) const previewParamSyncTimerRef = React.useRef | null>(null) + const resolvedObjectKey = React.useMemo(() => String(object?.Key ?? objectKey ?? ""), [object?.Key, objectKey]) + const objectPermissionContext = React.useMemo( + () => ({ + bucket: bucketName, + objectKey: resolvedObjectKey || undefined, + }), + [bucketName, resolvedObjectKey], + ) + const canDownloadObject = canCapability("objects.download", objectPermissionContext) + const canPreviewObject = canCapability("objects.preview", objectPermissionContext) + const canViewObjectTags = canCapability("objects.tag.view", objectPermissionContext) + const canEditObjectTags = canCapability("objects.tag.edit", objectPermissionContext) + const canViewVersions = canCapability("objects.version.view", objectPermissionContext) + const canEditLegalHold = canCapability("objects.legalHold.edit", objectPermissionContext) + const canEditRetention = canCapability("objects.retention.edit", objectPermissionContext) + const canShareObject = canCapability("objects.share", objectPermissionContext) const formatDuration = (seconds: number) => { if (seconds === 0) return "" @@ -201,7 +219,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie }, [open, objectKey, message, t]) const download = async () => { - if (!object?.Key) return + if (!canDownloadObject || !object?.Key) return try { const url = await objectApi.getSignedUrl(object.Key as string) const response = await fetch(url) @@ -218,9 +236,10 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie } React.useEffect(() => { + const timer = previewParamSyncTimerRef.current return () => { - if (previewParamSyncTimerRef.current) { - clearTimeout(previewParamSyncTimerRef.current) + if (timer) { + clearTimeout(timer) } } }, []) @@ -255,7 +274,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie } } const toggleLegalHold = async () => { - if (!object?.Key) return + if (!canEditLegalHold || !object?.Key) return try { await objectApi.setLegalHold(object.Key as string, !lockStatus) setLockStatus(!lockStatus) @@ -266,7 +285,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie } const generateTemporaryUrl = async () => { - if (!object?.Key || isGeneratingUrl) return + if (!canShareObject || !object?.Key || isGeneratingUrl) return if (!validateExpiration()) { message.error(expirationError || t("Please enter a valid expiration time")) return @@ -285,7 +304,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie const submitTagForm = async (e: React.FormEvent) => { e.preventDefault() - if (!object?.Key) return + if (!canEditObjectTags || !object?.Key) return if (!tagFormValue.Key || !tagFormValue.Value) { message.error(t("Please fill in the correct format")) return @@ -312,7 +331,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie } const removeTag = async (tagKey: string) => { - if (!object?.Key) return + if (!canEditObjectTags || !object?.Key) return try { const nextTags = tags.filter((tag) => tag.Key !== tagKey) await objectApi.putObjectTags(object.Key as string, nextTags) @@ -325,7 +344,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie const submitRetention = async (e: React.FormEvent) => { e.preventDefault() - if (!object?.Key) return + if (!canEditRetention || !object?.Key) return try { await objectApi.putObjectRetention(object.Key as string, { Mode: retentionMode, @@ -340,7 +359,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie } const resetRetention = async () => { - if (!object?.Key) return + if (!canEditRetention || !object?.Key) return try { await objectApi.putObjectRetention(object.Key as string, { Mode: "GOVERNANCE", @@ -364,28 +383,36 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
- - - - - {lockStatus && ( + {canDownloadObject ? ( + + ) : null} + {canPreviewObject ? ( + + ) : null} + {canViewObjectTags || canEditObjectTags ? ( + + ) : null} + {canViewVersions ? ( + + ) : null} + {lockStatus && canEditRetention ? ( - )} + ) : null}
@@ -433,7 +460,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
{t("Legal Hold")} - +
@@ -519,7 +546,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie variant="default" size="sm" className="shrink-0" - disabled={!object?.Key || !isExpirationValid || isGeneratingUrl} + disabled={!canShareObject || !object?.Key || !isExpirationValid || isGeneratingUrl} onClick={generateTemporaryUrl} > {isGeneratingUrl ? : null} @@ -557,13 +584,15 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie {tags.map((tag) => ( {tag.Key}: {tag.Value} - + {canEditObjectTags ? ( + + ) : null} ))}
@@ -576,6 +605,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie value={tagFormValue.Key} onChange={(e) => setTagFormValue((v) => ({ ...v, Key: e.target.value }))} placeholder={t("Tag Key Placeholder")} + disabled={!canEditObjectTags} /> @@ -591,12 +621,13 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie })) } placeholder={t("Tag Value Placeholder")} + disabled={!canEditObjectTags} />
-
@@ -645,10 +676,10 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
- - - + {canCapability("objects.preview", { bucket, objectKey: row.original.Key }) ? ( + + ) : null} + {canCapability("objects.download", { bucket, objectKey: row.original.Key }) ? ( + + ) : null} ) : null} - + {canCapability("objects.delete", { bucket, objectKey: row.original.Key }) ? ( + + ) : null}
), }, ], - [t, displayKey, bucketPath, onOpenInfo, bucket, prefix, downloadFile], + [t, displayKey, bucketPath, onOpenInfo, bucket, downloadFile, canCapability, onPreview], ) const { table, selectedRowIds } = useDataTable({ @@ -526,22 +538,28 @@ export function ObjectList({ actions={ <> - + {canUpload ? ( + + ) : null} {checkedKeys.length > 0 ? ( <> - - + {canBulkDelete ? ( + + ) : null} + {canBulkDownload ? ( + + ) : null} ) : null}
- - @@ -517,7 +541,7 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
-
diff --git a/components/object/versions.tsx b/components/object/versions.tsx index 29acbe45..db00a9e5 100644 --- a/components/object/versions.tsx +++ b/components/object/versions.tsx @@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { DataTable } from "@/components/data-table/data-table" import { useDataTable } from "@/hooks/use-data-table" import { useObject } from "@/hooks/use-object" +import { usePermissions } from "@/hooks/use-permissions" import { useMessage } from "@/lib/feedback/message" import { copyToClipboard } from "@/lib/clipboard" import { exportFile } from "@/lib/export-file" @@ -46,6 +47,7 @@ export function ObjectVersions({ const { t } = useTranslation() const message = useMessage() const { listObjectVersions, deleteObject } = useObject(bucketName) + const { canCapability } = usePermissions() const client = useS3() const [versions, setVersions] = React.useState([]) @@ -167,25 +169,43 @@ export function ObjectVersions({ { id: "actions", header: () => t("Action"), - cell: ({ row }) => ( -
- - - -
- ), + cell: ({ row }) => { + const objectContext = { + bucket: bucketName, + objectKey, + } + + return ( +
+ {canCapability("objects.version.view", objectContext) ? ( + + ) : null} + {canCapability("objects.download", objectContext) ? ( + + ) : null} + {canCapability("objects.delete", objectContext) ? ( + + ) : null} +
+ ) + }, }, ], - [t, onPreview, copyVersionId, downloadVersion, deleteVersion], + [t, onPreview, copyVersionId, downloadVersion, deleteVersion, canCapability, bucketName, objectKey], ) const { table } = useDataTable({ diff --git a/components/user/edit-form.tsx b/components/user/edit-form.tsx index 57d04631..0ccf7f4f 100644 --- a/components/user/edit-form.tsx +++ b/components/user/edit-form.tsx @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Spinner } from "@/components/ui/spinner" import { useUsers } from "@/hooks/use-users" +import { usePermissions } from "@/hooks/use-permissions" import { usePolicies } from "@/hooks/use-policies" import { useGroups } from "@/hooks/use-groups" import { useMessage } from "@/lib/feedback/message" @@ -42,8 +43,12 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor const { t } = useTranslation() const message = useMessage() const { getUser, changeUserStatus, createUser } = useUsers() + const { canCapability } = usePermissions() const { listPolicies, setUserOrGroupPolicy } = usePolicies() const { listGroup, updateGroupMembers } = useGroups() + const canEditAccount = canCapability("users.edit") + const canAssignGroups = canCapability("users.assignGroups") + const canEditPolicies = canCapability("users.policy.edit") const [user, setUser] = React.useState<{ accessKey: string @@ -66,6 +71,13 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor const [submitting, setSubmitting] = React.useState(false) const statusBoolean = user.status === "enabled" + const availableTabs = React.useMemo(() => { + const tabs: string[] = [] + if (canEditAccount) tabs.push("account") + if (canAssignGroups) tabs.push("groups") + if (canEditPolicies) tabs.push("policy") + return tabs + }, [canAssignGroups, canEditAccount, canEditPolicies]) React.useEffect(() => { if (!open || !row?.accessKey) return @@ -84,7 +96,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor setOriginalMemberOf(initialMemberOf) setSecretKey("") setErrors({ secretKey: "" }) - setActiveTab("account") + setActiveTab(availableTabs[0] ?? "account") setLoading(true) Promise.all([getUser(row.accessKey), listGroup(), listPolicies()]) @@ -111,7 +123,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor message.error(t("Failed to get data")) }) .finally(() => setLoading(false)) - }, [open, row, getUser, listGroup, listPolicies, message, t]) + }, [availableTabs, open, row, getUser, listGroup, listPolicies, message, t]) const validate = () => { const newErrors = { secretKey: "" } @@ -123,6 +135,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor } const handleStatusChange = async (checked: boolean) => { + if (!canEditAccount) return const nextStatus = checked ? "enabled" : "disabled" if (!user.accessKey) return @@ -146,7 +159,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor setSubmitting(true) try { - if (secretKey) { + if (canEditAccount && secretKey) { await createUser( { accessKey: user.accessKey, @@ -157,14 +170,16 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor ) } - await setUserOrGroupPolicy({ - policyName: user.policy, - userOrGroup: user.accessKey, - isGroup: false, - }) + if (canEditPolicies) { + await setUserOrGroupPolicy({ + policyName: user.policy, + userOrGroup: user.accessKey, + isGroup: false, + }) + } - const removedGroups = originalMemberOf.filter((group) => !user.memberOf.includes(group)) - const addedGroups = user.memberOf.filter((group) => !originalMemberOf.includes(group)) + const removedGroups = canAssignGroups ? originalMemberOf.filter((group) => !user.memberOf.includes(group)) : [] + const addedGroups = canAssignGroups ? user.memberOf.filter((group) => !originalMemberOf.includes(group)) : [] if (removedGroups.length) { await Promise.all( @@ -219,41 +234,51 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
- {t("Account")} - {t("Groups")} - {t("Policies")} + {canEditAccount ? {t("Account")} : null} + {canAssignGroups ? {t("Groups")} : null} + {canEditPolicies ? {t("Policies")} : null} - -
- {t("Status")} - -
- -
+ {canEditAccount ? ( + +
+ {t("Status")} + +
+ +
+ ) : null} - - setUser((prev) => ({ ...prev, memberOf }))} - /> - + {canAssignGroups ? ( + + setUser((prev) => ({ ...prev, memberOf }))} + /> + + ) : null} - - setUser((prev) => ({ ...prev, policy }))} - /> - + {canEditPolicies ? ( + + setUser((prev) => ({ ...prev, policy }))} + /> + + ) : null}
@@ -261,7 +286,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor - diff --git a/hooks/use-permissions.tsx b/hooks/use-permissions.tsx index c3bc50a1..d0b7203e 100644 --- a/hooks/use-permissions.tsx +++ b/hooks/use-permissions.tsx @@ -2,6 +2,8 @@ import { createContext, useContext, useState, useCallback, useEffect, useMemo } from "react" import { hasConsoleScopes, type ConsolePolicy } from "@/lib/console-policy-parser" +import { hasConsoleCapability, type ConsoleCapability } from "@/lib/permission-capabilities" +import type { PermissionResourceContext } from "@/lib/permission-resources" import { CONSOLE_SCOPES, PAGE_PERMISSIONS } from "@/lib/console-permissions" import { useAuth } from "@/contexts/auth-context" import { useApiOptional } from "@/contexts/api-context" @@ -14,6 +16,7 @@ interface PermissionsContextValue { hasFetchedPolicy: boolean fetchUserPolicy: () => Promise hasPermission: (action: string | string[], matchAll?: boolean) => boolean + canCapability: (capability: ConsoleCapability, context?: PermissionResourceContext) => boolean canAccessPath: (path: string) => boolean isAdmin: boolean /** True when user has consoleAdmin (or is admin). Only these users can change password per RustFS backend. */ @@ -89,6 +92,14 @@ export function PermissionsProvider({ children }: { children: React.ReactNode }) [isAdmin, userPolicy], ) + const canCapability = useCallback( + (capability: ConsoleCapability, context: PermissionResourceContext = {}) => { + if (isAdmin) return true + return hasConsoleCapability(userPolicy, capability, context) + }, + [isAdmin, userPolicy], + ) + const canAccessPath = useCallback( (path: string) => { if (isAdmin) return true @@ -131,6 +142,7 @@ export function PermissionsProvider({ children }: { children: React.ReactNode }) hasFetchedPolicy, fetchUserPolicy, hasPermission, + canCapability, canAccessPath, isAdmin, canChangePassword, @@ -143,6 +155,7 @@ export function PermissionsProvider({ children }: { children: React.ReactNode }) hasFetchedPolicy, fetchUserPolicy, hasPermission, + canCapability, canAccessPath, isAdmin, canChangePassword, diff --git a/lib/console-policy-parser.ts b/lib/console-policy-parser.ts index 9cc85845..3efd4fb4 100644 --- a/lib/console-policy-parser.ts +++ b/lib/console-policy-parser.ts @@ -6,6 +6,7 @@ export interface ConsoleStatement { Action?: string[] NotAction?: string[] Resource?: string[] + NotResource?: string[] } export interface ConsolePolicy { @@ -15,19 +16,38 @@ export interface ConsolePolicy { /** * Check if an action matches the policy actions - * @param policyActions - Array of action patterns (empty array means match all, undefined means no match) + * RustFS policy parser treats bare "*" as S3 wildcard only, not a cross-service wildcard. + * Some action names in the console still need to understand a few backend aliases. * @param requestAction - The action to check * @returns true if the action matches */ +const ACTION_ALIASES: Record = { + "*": "s3:*", + "s3:GetLifecycleConfiguration": "s3:GetBucketLifecycle", + "s3:GetBucketLifecycleConfiguration": "s3:GetBucketLifecycle", + "s3:PutLifecycleConfiguration": "s3:PutBucketLifecycle", + "s3:PutBucketLifecycleConfiguration": "s3:PutBucketLifecycle", + "s3:DeleteBucketLifecycle": "s3:PutBucketLifecycle", + "s3:DeleteBucketTagging": "s3:PutBucketTagging", + "s3:DeleteBucketReplication": "s3:PutReplicationConfiguration", +} + +function normalizeAction(action: string): string { + return ACTION_ALIASES[action] ?? action +} + +function isAdminAction(action: string): boolean { + return normalizeAction(action).startsWith("admin:") +} + function matchAction(policyActions: string[] | undefined, requestAction: string): boolean { - // Undefined means no match; explicit empty array means match all actions - if (policyActions === undefined) { + // Undefined or empty means no match. + if (!policyActions || policyActions.length === 0) { return false } - if (policyActions.length === 0) { - return true - } - return policyActions.some((pattern) => resourceMatch(pattern, requestAction)) + + const normalizedRequestAction = normalizeAction(requestAction) + return policyActions.some((pattern) => resourceMatch(normalizeAction(pattern), normalizedRequestAction)) } /** @@ -40,7 +60,9 @@ function matchNotAction(notActions: string[] | undefined, requestAction: string) if (!notActions || notActions.length === 0) { return false } - return notActions.some((pattern) => resourceMatch(pattern, requestAction)) + + const normalizedRequestAction = normalizeAction(requestAction) + return notActions.some((pattern) => resourceMatch(normalizeAction(pattern), normalizedRequestAction)) } const IMPLIED_SCOPES: Record = { @@ -51,13 +73,11 @@ const IMPLIED_SCOPES: Record = { "s3:GetObject", "s3:PutObject", "s3:DeleteObject", - "s3:ListObjects", "s3:GetObjectTagging", "s3:PutObjectTagging", "s3:DeleteObjectTagging", "s3:GetBucketTagging", "s3:PutBucketTagging", - "s3:DeleteBucketTagging", "s3:GetBucketPolicy", "s3:PutBucketPolicy", "s3:DeleteBucketPolicy", @@ -101,18 +121,71 @@ const IMPLIED_SCOPES: Record = { "s3:PutReplicationConfiguration", "s3:*", ], - [CONSOLE_SCOPES.VIEW_BUCKET_LIFECYCLE]: ["s3:GetLifecycleConfiguration", "s3:PutLifecycleConfiguration", "s3:*"], + [CONSOLE_SCOPES.VIEW_BUCKET_LIFECYCLE]: ["s3:GetBucketLifecycle", "s3:PutBucketLifecycle", "s3:*"], [CONSOLE_SCOPES.VIEW_TIERED_STORAGE]: ["admin:ConfigUpdate", "admin:*"], [CONSOLE_SCOPES.VIEW_EVENT_DESTINATIONS]: ["admin:ConfigUpdate", "admin:*"], [CONSOLE_SCOPES.VIEW_SSE_SETTINGS]: ["admin:ConfigUpdate", "admin:*", "kms:*"], [CONSOLE_SCOPES.VIEW_LICENSE]: ["admin:ServerInfo", "admin:*"], } -function matchResource(policyResources: string[] | undefined, requestResource: string): boolean { - if (!policyResources || policyResources.length === 0) { +function isConsoleResource(resource: string): boolean { + return resource === "console" || resource === "*" || resource.includes("console") +} + +function shouldIgnoreConsoleResourceCheck(statement: ConsoleStatement, action: string): boolean { + if (!isConsoleScope(action)) { + return false + } + + const statementResources = [...(statement.Resource ?? []), ...(statement.NotResource ?? [])] + return statementResources.length > 0 && !statementResources.some(isConsoleResource) +} + +function matchStatementResource(statement: ConsoleStatement, requestResource: string, action: string): boolean { + if (isAdminAction(action)) { return true } - return policyResources.some((pattern) => resourceMatch(pattern, requestResource)) + + if (shouldIgnoreConsoleResourceCheck(statement, action)) { + return true + } + + const resourceMatched = + !statement.Resource || statement.Resource.length === 0 + ? true + : statement.Resource.some((pattern) => resourceMatch(pattern, requestResource)) + + if (!resourceMatched) { + return false + } + + const notResourceMatched = + !!statement.NotResource && + statement.NotResource.length > 0 && + statement.NotResource.some((pattern) => resourceMatch(pattern, requestResource)) + + return !notResourceMatched +} + +function matchesRequestedAction(policyActions: string[] | undefined, action: string): boolean { + if (matchAction(policyActions, action)) { + return true + } + + if (!isConsoleScope(action)) { + return false + } + + if ( + matchAction(policyActions, CONSOLE_SCOPES.CONSOLE_ADMIN) || + matchAction(policyActions, "console:*") || + matchAction(policyActions, "admin:*") + ) { + return true + } + + const impliedActions = IMPLIED_SCOPES[action] + return !!impliedActions && impliedActions.some((implied) => matchAction(policyActions, implied)) } /** @@ -132,23 +205,6 @@ export function hasConsolePermission( const statements = Array.isArray(policy) ? policy : policy.Statement || [] if (statements.length === 0) return false - // For console scopes, we should ignore Resource restrictions if Resource doesn't match "console" - // This allows policies with S3 resources to still grant console permissions - const isConsoleAction = isConsoleScope(action) - const shouldCheckResource = (s: ConsoleStatement): boolean => { - // If action is a console scope and Resource is specified but doesn't match "console", - // we should still allow if Action matches (console scopes are management permissions) - if (isConsoleAction && s.Resource && s.Resource.length > 0) { - // Check if Resource contains console-related resources - const hasConsoleResource = s.Resource.some((r) => r === "console" || r === "*" || r.includes("console")) - // If Resource doesn't contain console resources, skip resource check for console actions - if (!hasConsoleResource) { - return false - } - } - return true - } - // Check Deny statements first const denied = statements.some((s) => { if (s.Effect !== "Deny") return false @@ -157,14 +213,14 @@ export function hasConsolePermission( if (s.NotAction && s.NotAction.length > 0) { // Deny if action is NOT in NotAction list if (!matchNotAction(s.NotAction, action)) { - return shouldCheckResource(s) ? matchResource(s.Resource, resource) : false + return matchStatementResource(s, resource, action) } return false } // If Action is present (or empty array), deny applies to matching actions - if (matchAction(s.Action, action)) { - return shouldCheckResource(s) ? matchResource(s.Resource, resource) : false + if (matchesRequestedAction(s.Action, action)) { + return matchStatementResource(s, resource, action) } return false @@ -181,17 +237,8 @@ export function hasConsolePermission( // If Action is also present, first check if action matches Action if (s.Action && s.Action.length > 0) { // Both Action and NotAction present: action must match Action AND not be in NotAction - const actionMatches = matchAction(s.Action, action) - const adminMatch = matchAction(s.Action, CONSOLE_SCOPES.CONSOLE_ADMIN) - const wildcardMatch = matchAction(s.Action, "console:*") - const adminStarMatch = matchAction(s.Action, "admin:*") - - if (!(actionMatches || adminMatch || wildcardMatch || adminStarMatch)) { - // Check implied actions - const impliedActions = IMPLIED_SCOPES[action] - if (!impliedActions || !impliedActions.some((implied) => matchAction(s.Action, implied))) { - return false // Action doesn't match Action list - } + if (!matchesRequestedAction(s.Action, action)) { + return false // Action doesn't match Action list } // Action matches Action list, now check NotAction exclusion @@ -205,30 +252,13 @@ export function hasConsolePermission( } } // Action is allowed (matches Action if present, and not in NotAction), check resource - return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true + return matchStatementResource(s, resource, action) } // Only Action is present (or empty array), allow applies to matching actions - const actionMatch = matchAction(s.Action, action) - const adminMatch = matchAction(s.Action, CONSOLE_SCOPES.CONSOLE_ADMIN) - const wildcardMatch = matchAction(s.Action, "console:*") - const adminStarMatch = matchAction(s.Action, "admin:*") - - const explicitMatch = - actionMatch || adminMatch || wildcardMatch || adminStarMatch - ? shouldCheckResource(s) - ? matchResource(s.Resource, resource) - : true - : false + const explicitMatch = matchesRequestedAction(s.Action, action) ? matchStatementResource(s, resource, action) : false if (explicitMatch) return true - const impliedActions = IMPLIED_SCOPES[action] - if (impliedActions) { - if (impliedActions.some((implied) => matchAction(s.Action, implied))) { - return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true - } - } - return false }) diff --git a/lib/permission-capabilities.ts b/lib/permission-capabilities.ts new file mode 100644 index 00000000..a4f759ab --- /dev/null +++ b/lib/permission-capabilities.ts @@ -0,0 +1,145 @@ +import { hasConsolePermission, type ConsolePolicy } from "./console-policy-parser" +import { + toAllBucketsArn, + toBucketArn, + toObjectArn, + toObjectPatternArn, + type PermissionResourceContext, +} from "./permission-resources" + +export type ConsoleCapability = + | "bucket.create" + | "bucket.delete" + | "bucket.policy.put" + | "bucket.policy.delete" + | "bucket.policy.edit" + | "bucket.encryption.edit" + | "bucket.tag.edit" + | "bucket.versioning.edit" + | "bucket.objectLock.edit" + | "bucket.quota.edit" + | "bucket.lifecycle.edit" + | "bucket.replication.edit" + | "bucket.events.edit" + | "objects.upload" + | "objects.view" + | "objects.preview" + | "objects.download" + | "objects.delete" + | "objects.bulkDelete" + | "objects.tag.view" + | "objects.tag.edit" + | "objects.legalHold.edit" + | "objects.retention.edit" + | "objects.version.view" + | "objects.share" + | "accessKeys.create" + | "accessKeys.edit" + | "accessKeys.delete" + | "accessKeys.bulkDelete" + | "users.create" + | "users.edit" + | "users.delete" + | "users.bulkDelete" + | "users.assignGroups" + | "users.policy.edit" + | "policies.create" + | "policies.edit" + | "policies.delete" + +type ResourceTarget = "none" | "bucket" | "object" | "objectPattern" | "allBuckets" +type RequirementMode = "all" | "any" + +interface CapabilityRequirement { + actions: string[] + mode?: RequirementMode + resource: ResourceTarget +} + +const CAPABILITY_REQUIREMENTS: Record = { + "bucket.create": [{ actions: ["s3:CreateBucket"], resource: "allBuckets" }], + "bucket.delete": [{ actions: ["s3:DeleteBucket"], resource: "bucket" }], + "bucket.policy.put": [{ actions: ["s3:PutBucketPolicy"], resource: "bucket" }], + "bucket.policy.delete": [{ actions: ["s3:DeleteBucketPolicy"], resource: "bucket" }], + "bucket.policy.edit": [{ actions: ["s3:PutBucketPolicy", "s3:DeleteBucketPolicy"], mode: "any", resource: "bucket" }], + "bucket.encryption.edit": [{ actions: ["s3:PutBucketEncryption"], resource: "bucket" }], + "bucket.tag.edit": [{ actions: ["s3:PutBucketTagging"], resource: "bucket" }], + "bucket.versioning.edit": [{ actions: ["s3:PutBucketVersioning"], resource: "bucket" }], + "bucket.objectLock.edit": [{ actions: ["s3:PutBucketObjectLockConfiguration"], resource: "bucket" }], + "bucket.quota.edit": [{ actions: ["admin:SetBucketQuota"], resource: "none" }], + "bucket.lifecycle.edit": [{ actions: ["s3:PutBucketLifecycle"], resource: "bucket" }], + "bucket.replication.edit": [{ actions: ["s3:PutReplicationConfiguration"], resource: "bucket" }], + "bucket.events.edit": [{ actions: ["s3:PutBucketNotification"], resource: "bucket" }], + "objects.upload": [{ actions: ["s3:PutObject"], resource: "objectPattern" }], + "objects.view": [{ actions: ["s3:GetObject"], resource: "object" }], + "objects.preview": [{ actions: ["s3:GetObject"], resource: "object" }], + "objects.download": [{ actions: ["s3:GetObject"], resource: "object" }], + "objects.delete": [{ actions: ["s3:DeleteObject"], resource: "object" }], + "objects.bulkDelete": [{ actions: ["s3:DeleteObject"], resource: "objectPattern" }], + "objects.tag.view": [{ actions: ["s3:GetObjectTagging"], resource: "object" }], + "objects.tag.edit": [{ actions: ["s3:PutObjectTagging"], resource: "object" }], + "objects.legalHold.edit": [{ actions: ["s3:PutObjectLegalHold"], resource: "object" }], + "objects.retention.edit": [{ actions: ["s3:PutObjectRetention"], resource: "object" }], + "objects.version.view": [{ actions: ["s3:GetObject"], resource: "object" }], + "objects.share": [{ actions: ["s3:GetObject"], resource: "object" }], + "accessKeys.create": [{ actions: ["admin:CreateServiceAccount"], resource: "none" }], + "accessKeys.edit": [{ actions: ["admin:UpdateServiceAccount"], resource: "none" }], + "accessKeys.delete": [{ actions: ["admin:RemoveServiceAccount"], resource: "none" }], + "accessKeys.bulkDelete": [{ actions: ["admin:RemoveServiceAccount"], resource: "none" }], + "users.create": [{ actions: ["admin:CreateUser"], resource: "none" }], + "users.edit": [ + { actions: ["admin:GetUser"], resource: "none" }, + { actions: ["admin:CreateUser", "admin:EnableUser", "admin:DisableUser"], mode: "any", resource: "none" }, + ], + "users.delete": [{ actions: ["admin:DeleteUser"], resource: "none" }], + "users.bulkDelete": [{ actions: ["admin:DeleteUser"], resource: "none" }], + "users.assignGroups": [ + { actions: ["admin:AddUserToGroup", "admin:RemoveUserFromGroup"], mode: "any", resource: "none" }, + ], + "users.policy.edit": [ + { actions: ["admin:AttachUserOrGroupPolicy", "admin:UpdatePolicyAssociation"], mode: "any", resource: "none" }, + ], + "policies.create": [{ actions: ["admin:CreatePolicy"], resource: "none" }], + "policies.edit": [{ actions: ["admin:CreatePolicy"], resource: "none" }], + "policies.delete": [{ actions: ["admin:DeletePolicy"], resource: "none" }], +} + +function resolveResource(resource: ResourceTarget, context: PermissionResourceContext): string { + switch (resource) { + case "none": + return "*" + case "bucket": + return context.bucket ? toBucketArn(context.bucket) : toAllBucketsArn() + case "object": + if (context.bucket && context.objectKey) { + return toObjectArn(context.bucket, context.objectKey) + } + return toObjectPatternArn(context.bucket, context.prefix) + case "objectPattern": + return toObjectPatternArn(context.bucket, context.prefix ?? context.objectKey) + case "allBuckets": + return toAllBucketsArn() + } +} + +export function hasConsoleCapability( + policy: ConsolePolicy | null | undefined, + capability: ConsoleCapability, + context: PermissionResourceContext = {}, +): boolean { + if (!policy) return false + + const requirements = CAPABILITY_REQUIREMENTS[capability] + if (!requirements) return false + + return requirements.every((requirement) => { + const resource = resolveResource(requirement.resource, context) + const mode = requirement.mode ?? "all" + + if (mode === "any") { + return requirement.actions.some((action) => hasConsolePermission(policy, action, resource)) + } + + return requirement.actions.every((action) => hasConsolePermission(policy, action, resource)) + }) +} diff --git a/lib/permission-resources.ts b/lib/permission-resources.ts new file mode 100644 index 00000000..4639e45c --- /dev/null +++ b/lib/permission-resources.ts @@ -0,0 +1,36 @@ +export interface PermissionResourceContext { + bucket?: string + objectKey?: string + prefix?: string +} + +const S3_ARN_PREFIX = "arn:aws:s3:::" + +function normalizeS3Path(path: string): string { + return path.replace(/^\/+/, "") +} + +export function toBucketArn(bucket: string): string { + return `${S3_ARN_PREFIX}${bucket}` +} + +export function toObjectArn(bucket: string, objectKey: string): string { + return `${toBucketArn(bucket)}/${normalizeS3Path(objectKey)}` +} + +export function toObjectPatternArn(bucket?: string, prefix?: string): string { + if (!bucket) { + return `${S3_ARN_PREFIX}*` + } + + if (!prefix) { + return `${toBucketArn(bucket)}/*` + } + + const normalizedPrefix = normalizeS3Path(prefix) + return `${toBucketArn(bucket)}/${normalizedPrefix}*` +} + +export function toAllBucketsArn(): string { + return `${S3_ARN_PREFIX}*` +} From 623bdddb407d0332f43f4839b9aa7b40ac766126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E7=99=BB=E5=B1=B1?= Date: Thu, 26 Mar 2026 07:57:57 +0800 Subject: [PATCH 2/5] fix: address preview review feedback --- app/(dashboard)/browser/content.tsx | 35 +++++++++++++++++++++++++++-- components/object/info.tsx | 10 --------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/app/(dashboard)/browser/content.tsx b/app/(dashboard)/browser/content.tsx index 053557c9..bcdb5f7c 100644 --- a/app/(dashboard)/browser/content.tsx +++ b/app/(dashboard)/browser/content.tsx @@ -25,7 +25,7 @@ interface BrowserContentProps { previewKey?: string } -export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps) { +export function BrowserContent({ bucketName, keyPath = "", preview = false, previewKey = "" }: BrowserContentProps) { const { t } = useTranslation() const router = useRouter() const searchParams = useSearchParams() @@ -98,6 +98,25 @@ export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps [objectApi], ) + const clearPreviewQueryParams = React.useCallback(() => { + if (!searchParams.has("preview") && !searchParams.has("previewKey")) return + + const params = new URLSearchParams(searchParams.toString()) + params.delete("preview") + params.delete("previewKey") + const query = params.toString() + router.replace(query ? `/browser?${query}` : "/browser") + }, [router, searchParams]) + + React.useEffect(() => { + if (!preview || !previewKey) return + + handleOpenPreview({ key: previewKey }).catch((error) => { + message.error((error as Error)?.message ?? t("Failed to fetch object info")) + clearPreviewQueryParams() + }) + }, [preview, previewKey, handleOpenPreview, message, t, clearPreviewQueryParams]) + const tasks = useTasks() const debounceTimerRef = React.useRef | null>(null) const prevCompletedIdsRef = React.useRef(new Set()) @@ -126,6 +145,18 @@ export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps } }, []) + const handlePreviewModalChange = React.useCallback( + (show: boolean) => { + setShowPreview(show) + + if (!show) { + setPreviewObject(null) + clearPreviewQueryParams() + } + }, + [clearPreviewQueryParams], + ) + return ( @@ -182,7 +213,7 @@ export function BrowserContent({ bucketName, keyPath = "" }: BrowserContentProps canUpload={canUploadObjects} /> - setShowPreview(show)} object={previewObject} /> + ) } diff --git a/components/object/info.tsx b/components/object/info.tsx index 0783d599..e978fb11 100644 --- a/components/object/info.tsx +++ b/components/object/info.tsx @@ -67,7 +67,6 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie const [totalExpirationSeconds, setTotalExpirationSeconds] = React.useState(0) const [isExpirationValid, setIsExpirationValid] = React.useState(false) const [isGeneratingUrl, setIsGeneratingUrl] = React.useState(false) - const previewParamSyncTimerRef = React.useRef | null>(null) const resolvedObjectKey = React.useMemo(() => String(object?.Key ?? objectKey ?? ""), [object?.Key, objectKey]) const objectPermissionContext = React.useMemo( () => ({ @@ -235,15 +234,6 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie } } - React.useEffect(() => { - const timer = previewParamSyncTimerRef.current - return () => { - if (timer) { - clearTimeout(timer) - } - } - }, []) - const handlePreviewVersion = async (versionId: string) => { if (!object?.Key) return try { From cbd1b29a76212149cc32e57c2fcfb149927f77ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E7=99=BB=E5=B1=B1?= Date: Thu, 26 Mar 2026 16:05:37 +0800 Subject: [PATCH 3/5] fix: align events permissions and account policy loading --- .claude/settings.local.json | 9 +++++++++ app/(dashboard)/events/page.tsx | 6 +++++- components/access-keys/new-item.tsx | 15 +++++++++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..2a20bba5 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(nvm use:*)", + "Bash(pnpm -C .. ls)", + "Bash(pnpm lint:*)" + ] + } +} diff --git a/app/(dashboard)/events/page.tsx b/app/(dashboard)/events/page.tsx index 9f31f9d5..6897f441 100644 --- a/app/(dashboard)/events/page.tsx +++ b/app/(dashboard)/events/page.tsx @@ -12,6 +12,7 @@ import { useDataTable } from "@/hooks/use-data-table" import { EventsNewForm } from "@/components/events/new-form" import { getEventsColumns } from "@/components/events/columns" import { useBucket } from "@/hooks/use-bucket" +import { usePermissions } from "@/hooks/use-permissions" import { useDialog } from "@/lib/feedback/dialog" import { useMessage } from "@/lib/feedback/message" import type { NotificationItem } from "@/lib/events" @@ -21,12 +22,15 @@ export default function EventsPage() { const message = useMessage() const dialog = useDialog() const { listBucketNotifications, putBucketNotifications } = useBucket() + const { canCapability } = usePermissions() const [bucketName, setBucketName] = useState(null) const [data, setData] = useState([]) const [loading, setLoading] = useState(false) const [newFormOpen, setNewFormOpen] = useState(false) + const canEditEvents = bucketName ? canCapability("bucket.events.edit", { bucket: bucketName }) : false + const loadData = useCallback(async () => { if (!bucketName) { setData([]) @@ -145,7 +149,7 @@ export default function EventsPage() { [bucketName, dialog, listBucketNotifications, loadData, message, putBucketNotifications, t], ) - const columns = useMemo(() => getEventsColumns(t, handleRowDelete, true), [t, handleRowDelete]) + const columns = useMemo(() => getEventsColumns(t, handleRowDelete, canEditEvents), [t, handleRowDelete, canEditEvents]) const { table } = useDataTable({ data, diff --git a/components/access-keys/new-item.tsx b/components/access-keys/new-item.tsx index 7de2f901..9c6221af 100644 --- a/components/access-keys/new-item.tsx +++ b/components/access-keys/new-item.tsx @@ -62,8 +62,19 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic setErrors({ accessKey: "", secretKey: "", name: "" }) api .get("/accountinfo") - .then((userInfo: { Policy?: unknown }) => { - setPolicy(JSON.stringify(userInfo?.Policy ?? {}, null, 2)) + .then((userInfo: any) => { + const rawPolicy = userInfo?.policy ?? userInfo?.Policy ?? {} + + if (typeof rawPolicy === "string") { + try { + const parsed = JSON.parse(rawPolicy) + setPolicy(JSON.stringify(parsed, null, 2)) + } catch { + setPolicy(rawPolicy) + } + } else { + setPolicy(JSON.stringify(rawPolicy ?? {}, null, 2)) + } }) .catch(() => { setPolicy("{}") From 7090ca0441c372d31a6a771d3ad6a25dd16bb029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E7=99=BB=E5=B1=B1?= Date: Thu, 26 Mar 2026 16:12:42 +0800 Subject: [PATCH 4/5] fix:fix formate --- .claude/settings.local.json | 3 ++- .gitignore | 1 + components/access-keys/new-item.tsx | 4 ++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 2a20bba5..8ffdc7d9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "Bash(nvm use:*)", "Bash(pnpm -C .. ls)", - "Bash(pnpm lint:*)" + "Bash(pnpm lint:*)", + "Bash(git add:*)" ] } } diff --git a/.gitignore b/.gitignore index 88406bed..847a367b 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ /.output/ /.vscode/ .cursor/ +/.claude/ # production /build diff --git a/components/access-keys/new-item.tsx b/components/access-keys/new-item.tsx index 9c6221af..22a19423 100644 --- a/components/access-keys/new-item.tsx +++ b/components/access-keys/new-item.tsx @@ -62,8 +62,8 @@ export function AccessKeysNewItem({ visible, onVisibleChange, onSuccess, onNotic setErrors({ accessKey: "", secretKey: "", name: "" }) api .get("/accountinfo") - .then((userInfo: any) => { - const rawPolicy = userInfo?.policy ?? userInfo?.Policy ?? {} + .then((userInfo: { policy?: unknown; Policy?: unknown }) => { + const rawPolicy = userInfo.policy ?? userInfo.Policy ?? {} if (typeof rawPolicy === "string") { try { From 0c191de708ae8dfa5ab0c35c767254bea1c56038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E7=99=BB=E5=B1=B1?= Date: Thu, 26 Mar 2026 16:23:04 +0800 Subject: [PATCH 5/5] fix --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 847a367b..9340f274 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ /.output/ /.vscode/ .cursor/ + +# claude ai workspace /.claude/ # production