feat: add button-level permission control (#89)

This commit is contained in:
cxymds
2026-03-26 16:43:21 +08:00
committed by GitHub
parent 13b1f4ddf8
commit 739321ac91
25 changed files with 1208 additions and 328 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(nvm use:*)",
"Bash(pnpm -C .. ls)",
"Bash(pnpm lint:*)",
"Bash(git add:*)"
]
}
}
+3
View File
@@ -22,6 +22,9 @@
/.vscode/
.cursor/
# claude ai workspace
/.claude/
# production
/build
+333
View File
@@ -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 页面中的细粒度按钮。
+26 -13
View File
@@ -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<RowData[]>([])
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 }) => (
<div className="flex justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
<Button variant="outline" size="sm" onClick={() => confirmDeleteSingle(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
{canEditAccessKey ? (
<Button variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
) : null}
{canDeleteAccessKey ? (
<Button variant="outline" size="sm" onClick={() => confirmDeleteSingle(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
@@ -207,16 +218,18 @@ export default function AccessKeysPage() {
clearable
className="max-w-xs"
/>
{selectedKeys.length > 0 && (
{selectedKeys.length > 0 && canBulkDeleteAccessKeys && (
<Button variant="outline" disabled={!selectedKeys.length} onClick={deleteSelected}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete Selected")}</span>
</Button>
)}
<Button variant="outline" onClick={() => setNewItemVisible(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Access Key")}</span>
</Button>
{canCreateAccessKey ? (
<Button variant="outline" onClick={() => setNewItemVisible(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Access Key")}</span>
</Button>
) : null}
</>
}
>
+37 -1
View File
@@ -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
@@ -29,6 +30,7 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
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<Record<string, unknown> | null>(null)
const objectApi = useObject(bucketName)
const canUploadObjects = canCapability("objects.upload", { bucket: bucketName, prefix })
React.useEffect(() => {
if (!bucketName) return
@@ -95,6 +98,25 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
[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<ReturnType<typeof setTimeout> | null>(null)
const prevCompletedIdsRef = React.useRef(new Set<string>())
@@ -123,6 +145,18 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
}
}, [])
const handlePreviewModalChange = React.useCallback(
(show: boolean) => {
setShowPreview(show)
if (!show) {
setPreviewObject(null)
clearPreviewQueryParams()
}
},
[clearPreviewQueryParams],
)
return (
<Page>
<PageHeader>
@@ -152,6 +186,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,9 +210,10 @@ export function BrowserContent({ bucketName, keyPath = "", preview = false, prev
onShowChange={setUploadPickerOpen}
bucketName={bucketName}
prefix={prefix}
canUpload={canUploadObjects}
/>
<ObjectPreviewModal show={showPreview} onShowChange={(show) => setShowPreview(show)} object={previewObject} />
<ObjectPreviewModal show={showPreview} onShowChange={handlePreviewModalChange} object={previewObject} />
</Page>
)
}
+16 -8
View File
@@ -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() {
<RiSettings5Line className="size-4" />
<span>{t("Settings")}</span>
</Button>
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
{canCapability("bucket.delete", { bucket: row.original.Name }) ? (
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
})
@@ -334,10 +340,12 @@ function BrowserBucketsPage() {
clearable
className="max-w-sm"
/>
<Button variant="outline" onClick={() => setFormVisible(true)}>
<RiAddLine className="size-4" />
<span>{t("Create Bucket")}</span>
</Button>
{canCreateBucket ? (
<Button variant="outline" onClick={() => setFormVisible(true)}>
<RiAddLine className="size-4" />
<span>{t("Create Bucket")}</span>
</Button>
) : null}
<Button variant="outline" onClick={() => fetchBuckets({ force: true })}>
<RiRefreshLine className="size-4" />
<span>{t("Refresh")}</span>
+5 -1
View File
@@ -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<string | null>(null)
const [data, setData] = useState<NotificationItem[]>([])
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), [t, handleRowDelete])
const columns = useMemo(() => getEventsColumns(t, handleRowDelete, canEditEvents), [t, handleRowDelete, canEditEvents])
const { table } = useDataTable<NotificationItem>({
data,
+23 -12
View File
@@ -11,6 +11,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 { PolicyForm, type PolicyItem } from "@/components/policies/form"
import { usePolicies } from "@/hooks/use-policies"
import { useDialog } from "@/lib/feedback/dialog"
@@ -22,6 +23,10 @@ export default function PoliciesPage() {
const message = useMessage()
const dialog = useDialog()
const { listPolicies: fetchPolicies, removePolicy } = usePolicies()
const { canCapability } = usePermissions()
const canCreatePolicy = canCapability("policies.create")
const canEditPolicy = canCapability("policies.edit")
const canDeletePolicy = canCapability("policies.delete")
const [data, setData] = useState<PolicyItem[]>([])
const [loading, setLoading] = useState(false)
@@ -88,14 +93,18 @@ export default function PoliciesPage() {
meta: { width: 200 },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handleEdit(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
{canEditPolicy ? (
<Button variant="outline" size="sm" onClick={() => handleEdit(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
) : null}
{canDeletePolicy ? (
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
@@ -140,10 +149,12 @@ export default function PoliciesPage() {
clearable
className="max-w-sm"
/>
<Button variant="outline" onClick={handleNew}>
<RiAddLine className="size-4" />
<span>{t("New Policy")}</span>
</Button>
{canCreatePolicy ? (
<Button variant="outline" onClick={handleNew}>
<RiAddLine className="size-4" />
<span>{t("New Policy")}</span>
</Button>
) : null}
</>
}
>
+39 -14
View File
@@ -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<UserRow[]>([])
const [loading, setLoading] = useState(false)
@@ -121,14 +130,18 @@ export default function UsersPage() {
meta: { width: 200 },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
{canEditUser ? (
<Button type="button" variant="outline" size="sm" onClick={() => openEditItem(row.original)}>
<RiEdit2Line className="size-4" />
<span>{t("Edit")}</span>
</Button>
) : null}
{canDeleteUser ? (
<Button type="button" variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
@@ -210,18 +223,30 @@ export default function UsersPage() {
clearable
className="max-w-xs"
/>
<Button type="button" variant="outline" disabled={!selectedKeys.length} onClick={deleteByList}>
<Button
type="button"
variant="outline"
disabled={!canBulkDeleteUsers || !selectedKeys.length}
onClick={deleteByList}
>
<RiDeleteBin5Line className="size-4" />
{t("Delete Selected")}
</Button>
<Button type="button" variant="outline" disabled={!selectedKeys.length} onClick={addToGroup}>
<Button
type="button"
variant="outline"
disabled={!canAssignGroups || !selectedKeys.length}
onClick={addToGroup}
>
<RiGroup2Fill className="size-4" />
{t("Add to Group")}
</Button>
<Button type="button" variant="outline" onClick={() => setNewFormOpen(true)}>
<RiAddLine className="size-4" />
{t("Add User")}
</Button>
{canCreateUser ? (
<Button type="button" variant="outline" onClick={() => setNewFormOpen(true)}>
<RiAddLine className="size-4" />
{t("Add User")}
</Button>
) : null}
</>
}
>
+13 -2
View File
@@ -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: { policy?: unknown; Policy?: unknown }) => {
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("{}")
+16 -6
View File
@@ -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<NotificationItem[]>([])
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<boolean>((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<NotificationItem>({
data,
@@ -152,10 +160,12 @@ export function BucketEventsTab({ bucketName }: BucketEventsTabProps) {
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">{t("Events")}</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Event Subscription")}</span>
</Button>
{canEditEvents ? (
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Event Subscription")}</span>
</Button>
) : null}
<Button variant="outline" onClick={loadData} disabled={loading}>
<RiRefreshLine className="size-4" />
<span>{t("Refresh")}</span>
+72 -21
View File
@@ -4,6 +4,7 @@ import * as React from "react"
import { useTranslation } from "react-i18next"
import { RiAddLine, RiCloseLine, RiEdit2Line } from "@remixicon/react"
import { useBucket } from "@/hooks/use-bucket"
import { usePermissions } from "@/hooks/use-permissions"
import { useSSE } from "@/hooks/use-sse"
import { Item, ItemContent, ItemDescription, ItemGroup, ItemHeader, ItemTitle, ItemActions } from "@/components/ui/item"
import { Button } from "@/components/ui/button"
@@ -61,7 +62,17 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const { t } = useTranslation()
const message = useMessage()
const bucketApi = useBucket()
const { canCapability } = usePermissions()
const { getKeyList } = useSSE()
const bucketContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName])
const canPutBucketPolicy = canCapability("bucket.policy.put", bucketContext)
const canDeleteBucketPolicy = canCapability("bucket.policy.delete", bucketContext)
const canEditBucketPolicy = canPutBucketPolicy || canDeleteBucketPolicy
const canEditEncryption = canCapability("bucket.encryption.edit", bucketContext)
const canEditTags = canCapability("bucket.tag.edit", bucketContext)
const canEditVersioning = canCapability("bucket.versioning.edit", bucketContext)
const canEditQuota = canCapability("bucket.quota.edit", bucketContext)
const canEditObjectLock = canCapability("bucket.objectLock.edit", bucketContext)
const [policy, setPolicy] = React.useState<string | null>(null)
const [policyType, setPolicyType] = React.useState<BucketPolicyType | "custom">("private")
@@ -181,6 +192,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}, [fetchData])
const openPolicyModal = () => {
if (!canEditBucketPolicy) return
setPolicyFormPolicy(policyType)
setPolicyFormContent(policy ?? "{}")
setShowPolicyModal(true)
@@ -188,6 +200,15 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
const submitPolicy = async () => {
try {
if (policyFormPolicy === "private" && !canDeleteBucketPolicy) {
message.error(t("Edit Failed"))
return
}
if (policyFormPolicy !== "private" && !canPutBucketPolicy) {
message.error(t("Edit Failed"))
return
}
if (policyFormPolicy === "private") {
await bucketApi.deleteBucketPolicy(bucketName)
} else if (policyFormPolicy === "custom") {
@@ -206,6 +227,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const openEncryptModal = async () => {
if (!canEditEncryption) return
const enc = encryption as {
ServerSideEncryptionConfiguration?: {
Rules?: Array<{
@@ -281,6 +303,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const openTagModal = (index = -1) => {
if (!canEditTags) return
setEditingTagIndex(index)
if (index >= 0 && tags[index]) {
setTagFormKey(tags[index].Key)
@@ -293,6 +316,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const submitTag = async () => {
if (!canEditTags) return
if (!tagFormKey.trim() || !tagFormValue.trim()) {
message.error(t("Please fill in complete tag information"))
return
@@ -314,6 +338,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const confirmDeleteTag = async () => {
if (!canEditTags) return
if (deleteTagIndex === null) return
const next = tags.filter((_, i) => i !== deleteTagIndex)
try {
@@ -331,6 +356,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const handleVersionToggle = async (enabled: boolean) => {
if (!canEditVersioning) return
setVersionLoading(true)
const prev = versioning
try {
@@ -346,6 +372,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const openQuotaModal = () => {
if (!canEditQuota) return
if (quotaInfo?.quota && quotaInfo.quota > 0) {
const bytes = quotaInfo.quota
const units = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]
@@ -365,6 +392,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const submitQuota = async () => {
if (!canEditQuota) return
try {
if (!quotaFormEnabled) {
await bucketApi.deleteBucketQuota(bucketName)
@@ -383,6 +411,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const openRetentionModal = () => {
if (!canEditObjectLock) return
if (!objectLock) {
message.error(t("Object lock is not enabled, cannot set retention"))
return
@@ -394,6 +423,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
}
const submitRetention = async () => {
if (!canEditObjectLock) return
const mode = retentionFormMode
const unit = retentionFormUnit
const period = retentionPeriodInput ? Math.max(1, Number.parseInt(retentionPeriodInput, 10) || 1) : null
@@ -447,10 +477,12 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
<ItemHeader className="items-center">
<ItemTitle>{t("Access Policy")}</ItemTitle>
<ItemActions>
<Button variant="outline" size="sm" className="shrink-0" onClick={openPolicyModal}>
<RiEdit2Line className="me-2 size-4" />
{t("Edit")}
</Button>
{canEditBucketPolicy ? (
<Button variant="outline" size="sm" className="shrink-0" onClick={openPolicyModal}>
<RiEdit2Line className="me-2 size-4" />
{t("Edit")}
</Button>
) : null}
</ItemActions>
</ItemHeader>
<ItemContent>
@@ -466,10 +498,12 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
<ItemDescription className="text-xs text-muted-foreground">{encryptionLabel}</ItemDescription>
</div>
<ItemActions>
<Button variant="outline" size="sm" className="shrink-0" onClick={openEncryptModal}>
<RiEdit2Line className="me-2 size-4" />
{t("Edit")}
</Button>
{canEditEncryption ? (
<Button variant="outline" size="sm" className="shrink-0" onClick={openEncryptModal}>
<RiEdit2Line className="me-2 size-4" />
{t("Edit")}
</Button>
) : null}
</ItemActions>
</ItemHeader>
</Item>
@@ -479,10 +513,12 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
<ItemHeader className="items-center">
<ItemTitle>{t("Tag")}</ItemTitle>
<ItemActions>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => openTagModal()}>
<RiAddLine className="me-2 size-4" />
{t("Add")}
</Button>
{canEditTags ? (
<Button variant="outline" size="sm" className="shrink-0" onClick={() => openTagModal()}>
<RiAddLine className="me-2 size-4" />
{t("Add")}
</Button>
) : null}
</ItemActions>
</ItemHeader>
<ItemContent>
@@ -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"
>
<button type="button" className="text-left hover:underline" onClick={() => openTagModal(index)}>
<button
type="button"
className="text-left hover:underline disabled:no-underline"
onClick={() => openTagModal(index)}
disabled={!canEditTags}
>
{tag.Key}:{tag.Value}
</button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setDeleteTagIndex(index)}>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => setDeleteTagIndex(index)}
disabled={!canEditTags}
>
<RiCloseLine className="size-3.5" />
</Button>
</div>
@@ -525,7 +572,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
</div>
<Switch
checked={versioning === "Enabled"}
disabled={(objectLock ?? false) || versionLoading}
disabled={!canEditVersioning || (objectLock ?? false) || versionLoading}
onCheckedChange={handleVersionToggle}
/>
</div>
@@ -542,9 +589,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
</ItemDescription>
</div>
<ItemActions>
<Button variant="outline" size="sm" className="shrink-0" onClick={openQuotaModal}>
{t("Edit")}
</Button>
{canEditQuota ? (
<Button variant="outline" size="sm" className="shrink-0" onClick={openQuotaModal}>
{t("Edit")}
</Button>
) : null}
</ItemActions>
</ItemHeader>
{quotaInfo?.quota ? (
@@ -575,9 +624,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
</ItemDescription>
</div>
<ItemActions>
<Button variant="outline" size="sm" className="shrink-0" onClick={openRetentionModal}>
{t("Edit")}
</Button>
{canEditObjectLock ? (
<Button variant="outline" size="sm" className="shrink-0" onClick={openRetentionModal}>
{t("Edit")}
</Button>
) : null}
</ItemActions>
</ItemHeader>
<ItemContent>
+19 -10
View File
@@ -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<LifecycleRule[]>([])
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 }) => (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" aria-hidden />
<span>{t("Delete")}</span>
</Button>
{canEditLifecycle ? (
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin5Line className="size-4" aria-hidden />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
],
[t, confirmDelete],
[canEditLifecycle, t, confirmDelete],
)
const { table } = useDataTable<LifecycleRule>({
@@ -181,10 +188,12 @@ export function BucketLifecycleTab({ bucketName }: BucketLifecycleTabProps) {
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">{t("Lifecycle")}</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Lifecycle Rule")}</span>
</Button>
{canEditLifecycle ? (
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Lifecycle Rule")}</span>
</Button>
) : null}
<Button variant="outline" onClick={loadData}>
<RiRefreshLine className="size-4" />
<span>{t("Refresh")}</span>
+16 -9
View File
@@ -10,6 +10,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useMessage } from "@/lib/feedback/message"
import { useBucket } from "@/hooks/use-bucket"
import { usePermissions } from "@/hooks/use-permissions"
import { getBytes } from "@/lib/functions"
import { cn } from "@/lib/utils"
@@ -32,6 +33,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
const { t } = useTranslation()
const message = useMessage()
const { createBucket, putBucketVersioning, putObjectLockConfiguration, putBucketQuota } = useBucket()
const { canCapability } = usePermissions()
const [objectKey, setObjectKey] = React.useState("")
const [version, setVersion] = React.useState(false)
@@ -49,6 +51,11 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
const showNameError = trimmedBucketName.length > 0 && (trimmedBucketName.length < 3 || trimmedBucketName.length > 63)
const parsedRetentionPeriod = Math.max(1, Number.parseInt(retentionPeriod, 10) || 1)
const isSubmitDisabled = creating || trimmedBucketName.length < 3 || trimmedBucketName.length > 63
const canCreateBucket = canCapability("bucket.create")
const bucketContext = trimmedBucketName ? { bucket: trimmedBucketName } : {}
const canEditVersioning = canCapability("bucket.versioning.edit", bucketContext)
const canEditObjectLock = canCapability("bucket.objectLock.edit", bucketContext)
const canEditQuota = canCapability("bucket.quota.edit", bucketContext)
React.useEffect(() => {
if (objectLock) setVersion(true)
@@ -77,7 +84,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
}
const handleCreateBucket = async () => {
if (isSubmitDisabled) return
if (!canCreateBucket || isSubmitDisabled) return
const bucketName = trimmedBucketName
setCreating(true)
@@ -85,11 +92,11 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
try {
await createBucket({
Bucket: bucketName,
ObjectLockEnabledForBucket: objectLock,
ObjectLockEnabledForBucket: canEditObjectLock && objectLock,
})
const applyRetention = async () => {
if (objectLock && retentionEnabled) {
if (canEditObjectLock && objectLock && retentionEnabled) {
await putObjectLockConfiguration(bucketName, {
ObjectLockEnabled: "Enabled",
Rule: {
@@ -103,11 +110,11 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
}
}
if (version) {
if (canEditVersioning && version) {
await putBucketVersioning(bucketName, "Enabled")
}
if (quotaEnabled) {
if (canEditQuota && quotaEnabled) {
try {
await putBucketQuota(bucketName, {
quota: Number.parseInt(getBytes(quotaSize, quotaUnit), 10),
@@ -162,21 +169,21 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
<Field orientation="responsive" className="items-center">
<FieldLabel>{t("Version")}</FieldLabel>
<FieldContent className="flex justify-end">
<Switch checked={version} onCheckedChange={setVersion} />
<Switch checked={version} onCheckedChange={setVersion} disabled={!canEditVersioning} />
</FieldContent>
</Field>
<Field orientation="responsive" className="items-center">
<FieldLabel>{t("Object Lock")}</FieldLabel>
<FieldContent className="flex justify-end">
<Switch checked={objectLock} onCheckedChange={setObjectLock} />
<Switch checked={objectLock} onCheckedChange={setObjectLock} disabled={!canEditObjectLock} />
</FieldContent>
</Field>
<Field orientation="responsive" className="items-center">
<FieldLabel>{t("Bucket Quota")}</FieldLabel>
<FieldContent className="flex justify-end">
<Switch checked={quotaEnabled} onCheckedChange={setQuotaEnabled} />
<Switch checked={quotaEnabled} onCheckedChange={setQuotaEnabled} disabled={!canEditQuota} />
</FieldContent>
</Field>
@@ -280,7 +287,7 @@ export function BucketNewForm({ show, onShowChange }: BucketNewFormProps) {
<Button variant="outline" onClick={closeModal}>
{t("Cancel")}
</Button>
<Button variant="default" disabled={isSubmitDisabled} onClick={handleCreateBucket}>
<Button variant="default" disabled={!canCreateBucket || isSubmitDisabled} onClick={handleCreateBucket}>
{creating ? (
<span className="flex items-center gap-2">
<span className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
+19 -9
View File
@@ -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 { ReplicationNewForm } from "@/components/replication/new-form"
import { useDialog } from "@/lib/feedback/dialog"
import { useMessage } from "@/lib/feedback/message"
@@ -29,8 +30,11 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
const { t } = useTranslation()
const message = useMessage()
const dialog = useDialog()
const { canCapability } = usePermissions()
const { getBucketReplication, putBucketReplication, deleteBucketReplication, deleteRemoteReplicationTarget } =
useBucket()
const replicationContext = React.useMemo(() => ({ bucket: bucketName }), [bucketName])
const canEditReplication = canCapability("bucket.replication.edit", replicationContext)
const [data, setData] = React.useState<ReplicationRule[]>([])
const [loading, setLoading] = React.useState(false)
@@ -54,6 +58,7 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
const handleRowDelete = React.useCallback(
async (rule: ReplicationRule) => {
if (!canEditReplication) return
const remaining = data.filter((item) => item !== rule)
try {
@@ -78,6 +83,7 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
}
},
[
canEditReplication,
data,
deleteBucketReplication,
bucketName,
@@ -148,15 +154,17 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
enableSorting: false,
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin7Line className="size-4" aria-hidden />
<span>{t("Delete")}</span>
</Button>
{canEditReplication ? (
<Button variant="outline" size="sm" onClick={() => confirmDelete(row.original)}>
<RiDeleteBin7Line className="size-4" aria-hidden />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
],
[t, confirmDelete],
[canEditReplication, t, confirmDelete],
)
const { table } = useDataTable<ReplicationRule>({
@@ -170,10 +178,12 @@ export function BucketReplicationTab({ bucketName }: BucketReplicationTabProps)
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">{t("Bucket Replication")}</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Replication Rule")}</span>
</Button>
{canEditReplication ? (
<Button variant="outline" onClick={() => setNewFormOpen(true)}>
<RiAddLine className="size-4" />
<span>{t("Add Replication Rule")}</span>
</Button>
) : null}
<Button variant="outline" onClick={loadData}>
<RiRefreshLine className="size-4" />
<span>{t("Refresh")}</span>
+16 -13
View File
@@ -10,6 +10,7 @@ import type { TFunction } from "i18next"
export function getEventsColumns(
t: TFunction,
onDelete: (row: NotificationItem) => void,
canDelete: boolean,
): ColumnDef<NotificationItem>[] {
return [
{
@@ -57,19 +58,21 @@ export function getEventsColumns(
meta: { maxWidth: "6rem" },
cell: ({ row }) => (
<div className="flex justify-center">
<Button
type="button"
size="sm"
variant="outline"
className="gap-2"
onClick={(e) => {
e.stopPropagation()
onDelete(row.original)
}}
>
<RiDeleteBin7Line className="size-4" aria-hidden />
<span>{t("Delete")}</span>
</Button>
{canDelete ? (
<Button
type="button"
size="sm"
variant="outline"
className="gap-2"
onClick={(e) => {
e.stopPropagation()
onDelete(row.original)
}}
>
<RiDeleteBin7Line className="size-4" aria-hidden />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
+67 -46
View File
@@ -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<Record<string, unknown> | null>(null)
@@ -65,7 +67,22 @@ 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<ReturnType<typeof setTimeout> | 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 +218,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)
@@ -217,14 +234,6 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
}
}
React.useEffect(() => {
return () => {
if (previewParamSyncTimerRef.current) {
clearTimeout(previewParamSyncTimerRef.current)
}
}
}, [])
const handlePreviewVersion = async (versionId: string) => {
if (!object?.Key) return
try {
@@ -255,7 +264,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 +275,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 +294,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 +321,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 +334,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 +349,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 +373,36 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
</DrawerHeader>
<div className="min-w-0 space-y-4 p-4 overflow-hidden">
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={download}>
<RiDownloadLine className="size-4" />
{t("Download")}
</Button>
<Button variant="outline" size="sm" onClick={() => onPreview({ key: String(object?.Key ?? "") })}>
<RiEyeLine className="size-4" />
{t("Preview")}
</Button>
<Button variant="outline" size="sm" onClick={() => setShowTagView(true)}>
<RiPriceTag3Line className="size-4" />
{t("Set Tags")}
</Button>
<Button variant="outline" size="sm" onClick={() => setShowVersions(true)}>
<RiFileList2Line className="size-4" />
{t("Versions")}
</Button>
{lockStatus && (
{canDownloadObject ? (
<Button variant="outline" size="sm" onClick={download}>
<RiDownloadLine className="size-4" />
{t("Download")}
</Button>
) : null}
{canPreviewObject ? (
<Button variant="outline" size="sm" onClick={() => onPreview({ key: String(object?.Key ?? "") })}>
<RiEyeLine className="size-4" />
{t("Preview")}
</Button>
) : null}
{canViewObjectTags || canEditObjectTags ? (
<Button variant="outline" size="sm" onClick={() => setShowTagView(true)}>
<RiPriceTag3Line className="size-4" />
{t("Set Tags")}
</Button>
) : null}
{canViewVersions ? (
<Button variant="outline" size="sm" onClick={() => setShowVersions(true)}>
<RiFileList2Line className="size-4" />
{t("Versions")}
</Button>
) : null}
{lockStatus && canEditRetention ? (
<Button variant="outline" size="sm" onClick={() => setShowRetentionView(true)}>
<RiLockLine className="size-4" />
{t("Retention")}
</Button>
)}
) : null}
</div>
<Item variant="outline" className="flex-col items-stretch gap-4">
@@ -433,7 +450,7 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
</div>
<div className="flex items-center justify-between gap-3 min-w-0">
<span className="font-medium text-muted-foreground">{t("Legal Hold")}</span>
<Switch checked={lockStatus} onCheckedChange={toggleLegalHold} />
<Switch checked={lockStatus} onCheckedChange={toggleLegalHold} disabled={!canEditLegalHold} />
</div>
<div className="flex flex-col gap-2">
<span className="font-medium text-muted-foreground">
@@ -519,7 +536,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 ? <Spinner className="size-4" /> : null}
@@ -557,13 +574,15 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
{tags.map((tag) => (
<Badge key={tag.Key} variant="secondary" className="gap-1 pr-1">
{tag.Key}: {tag.Value}
<button
type="button"
className="hover:text-destructive transition-colors"
onClick={() => confirmDeleteTag(tag.Key)}
>
<RiCloseLine className="size-3" />
</button>
{canEditObjectTags ? (
<button
type="button"
className="hover:text-destructive transition-colors"
onClick={() => confirmDeleteTag(tag.Key)}
>
<RiCloseLine className="size-3" />
</button>
) : null}
</Badge>
))}
</div>
@@ -576,6 +595,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}
/>
</FieldContent>
</Field>
@@ -591,12 +611,13 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
}))
}
placeholder={t("Tag Value Placeholder")}
disabled={!canEditObjectTags}
/>
</FieldContent>
</Field>
</div>
<div className="flex justify-end">
<Button type="submit" variant="default">
<Button type="submit" variant="default" disabled={!canEditObjectTags}>
{t("Add")}
</Button>
</div>
@@ -645,10 +666,10 @@ export function ObjectInfo({ bucketName, objectKey, open, onOpenChange, onPrevie
</FieldContent>
</Field>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={resetRetention}>
<Button type="button" variant="secondary" onClick={resetRetention} disabled={!canEditRetention}>
{t("Reset")}
</Button>
<Button type="submit" variant="default">
<Button type="submit" variant="default" disabled={!canEditRetention}>
{t("Confirm")}
</Button>
<Button type="button" variant="outline" onClick={() => setShowRetentionView(false)}>
+51 -33
View File
@@ -33,6 +33,7 @@ import { useDataTable } from "@/hooks/use-data-table"
import { useObject } from "@/hooks/use-object"
import { useBucket } from "@/hooks/use-bucket"
import { useLocalStorage } from "@/hooks/use-local-storage"
import { usePermissions } from "@/hooks/use-permissions"
import { useMessage } from "@/lib/feedback/message"
import { exportFile } from "@/lib/export-file"
import { getContentType } from "@/lib/mime-types"
@@ -57,6 +58,7 @@ interface ObjectListProps {
path: string
onOpenInfo: (bucket: string, key: string) => void
onUploadClick: () => void
canUpload?: boolean
pageSize?: number
onRefresh?: () => void
refreshTrigger?: number
@@ -68,6 +70,7 @@ export function ObjectList({
path,
onOpenInfo,
onUploadClick,
canUpload = false,
pageSize = 25,
onRefresh,
refreshTrigger = 0,
@@ -77,6 +80,7 @@ export function ObjectList({
const message = useMessage()
const { listObject, getSignedUrl, mapAllFiles } = useObject(bucket)
const { getBucketVersioning } = useBucket()
const { canCapability } = usePermissions()
const addDeleteKeys = useAddDeleteKeys()
const addDeleteFolder = useAddDeleteFolder()
const tasks = useTasks()
@@ -94,6 +98,8 @@ export function ObjectList({
const [deleteAllVersions, setDeleteAllVersions] = React.useState(false)
const prefix = decodeURIComponent(path)
const canBulkDelete = canCapability("objects.bulkDelete", { bucket, prefix })
const canBulkDownload = canCapability("objects.download", { bucket, prefix })
const bucketPath = React.useCallback((p?: string | string[]) => buildBucketPath(bucket, p), [bucket])
@@ -288,31 +294,37 @@ export function ObjectList({
<div className="flex items-center gap-2">
{row.original.type === "object" ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
onPreview({ key: row.original.Key })
}}
>
<RiEyeLine className="size-4" />
<span>{t("Preview")}</span>
</Button>
<Button variant="outline" size="sm" onClick={() => downloadFile(row.original.Key)}>
<RiDownloadCloud2Line className="size-4" />
<span>{t("Download")}</span>
</Button>
{canCapability("objects.preview", { bucket, objectKey: row.original.Key }) ? (
<Button
variant="outline"
size="sm"
onClick={() => {
onPreview({ key: row.original.Key })
}}
>
<RiEyeLine className="size-4" />
<span>{t("Preview")}</span>
</Button>
) : null}
{canCapability("objects.download", { bucket, objectKey: row.original.Key }) ? (
<Button variant="outline" size="sm" onClick={() => downloadFile(row.original.Key)}>
<RiDownloadCloud2Line className="size-4" />
<span>{t("Download")}</span>
</Button>
) : null}
</>
) : null}
<Button variant="outline" size="sm" onClick={() => openDeleteDialog([row.original.Key])}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
{canCapability("objects.delete", { bucket, objectKey: row.original.Key }) ? (
<Button variant="outline" size="sm" onClick={() => openDeleteDialog([row.original.Key])}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete")}</span>
</Button>
) : null}
</div>
),
},
],
[t, displayKey, bucketPath, onOpenInfo, bucket, prefix, downloadFile],
[t, displayKey, bucketPath, onOpenInfo, bucket, downloadFile, canCapability, onPreview],
)
const { table, selectedRowIds } = useDataTable<ObjectRow>({
@@ -526,22 +538,28 @@ export function ObjectList({
actions={
<>
<TaskStatsButton />
<Button variant="outline" onClick={onUploadClick}>
<RiFileAddLine className="size-4" />
<span>
{t("Upload File")}/{t("Folder")}
</span>
</Button>
{canUpload ? (
<Button variant="outline" onClick={onUploadClick}>
<RiFileAddLine className="size-4" />
<span>
{t("Upload File")}/{t("Folder")}
</span>
</Button>
) : null}
{checkedKeys.length > 0 ? (
<>
<Button variant="outline" className="text-destructive border-destructive" onClick={handleBatchDelete}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete Selected")}</span>
</Button>
<Button variant="outline" onClick={downloadMultiple}>
<RiDownloadCloud2Line className="size-4" />
<span>{t("Download")}</span>
</Button>
{canBulkDelete ? (
<Button variant="outline" className="text-destructive border-destructive" onClick={handleBatchDelete}>
<RiDeleteBin5Line className="size-4" />
<span>{t("Delete Selected")}</span>
</Button>
) : null}
{canBulkDownload ? (
<Button variant="outline" onClick={downloadMultiple}>
<RiDownloadCloud2Line className="size-4" />
<span>{t("Download")}</span>
</Button>
) : null}
</>
) : null}
<Button variant="outline" onClick={() => (onRefresh ?? fetchObjects)()}>
+35 -7
View File
@@ -23,6 +23,7 @@ interface ObjectUploadPickerProps {
onShowChange: (show: boolean) => void
bucketName: string
prefix: string
canUpload?: boolean
onSuccess?: () => void
}
@@ -182,7 +183,14 @@ function collectFilesFromFileList(files: FileList, basePath = ""): FileItem[] {
return items
}
export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onSuccess }: ObjectUploadPickerProps) {
export function ObjectUploadPicker({
show,
onShowChange,
bucketName,
prefix,
canUpload = false,
onSuccess,
}: ObjectUploadPickerProps) {
const { t } = useTranslation()
const message = useMessage()
const addUploadFiles = useAddUploadFiles()
@@ -214,6 +222,12 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
}
}, [show, prefix])
React.useEffect(() => {
if (show && !canUpload) {
onShowChange(false)
}
}, [canUpload, onShowChange, show])
const effectivePrefix = editablePrefix.replace(/\/$/, "") || ""
const ensureCapacity = React.useCallback(
@@ -396,7 +410,7 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
}
const handleUpload = async () => {
if (!items.length) return
if (!canUpload || !items.length) return
setIsAdding(true)
setAddProgress(0)
try {
@@ -466,16 +480,26 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
onChange={(e) => setEditablePrefix(e.target.value)}
className="h-7 py-1 px-2 text-xs"
placeholder={t("Enter prefix...")}
disabled={isAdding || isFolderLoading}
disabled={!canUpload || isAdding || isFolderLoading}
/>
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" disabled={isAdding || isFolderLoading} onClick={selectFile}>
<Button
variant="outline"
size="sm"
disabled={!canUpload || isAdding || isFolderLoading}
onClick={selectFile}
>
<RiFileAddLine className="size-4" />
{t("Select File")}
</Button>
<Button variant="outline" size="sm" disabled={isAdding || isFolderLoading} onClick={selectFolder}>
<Button
variant="outline"
size="sm"
disabled={!canUpload || isAdding || isFolderLoading}
onClick={selectFolder}
>
<RiFolderAddLine className="size-4" />
{t("Select Folder")}
</Button>
@@ -517,7 +541,7 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
<Button
variant="outline"
size="sm"
disabled={!items.length || isAdding || isFolderLoading}
disabled={!canUpload || !items.length || isAdding || isFolderLoading}
onClick={clearAllFiles}
>
<RiDeleteBinLine className="size-4" />
@@ -563,7 +587,11 @@ export function ObjectUploadPicker({ show, onShowChange, bucketName, prefix, onS
</div>
<div className="flex justify-end gap-2">
<Button variant="default" disabled={!items.length || isAdding || isFolderLoading} onClick={handleUpload}>
<Button
variant="default"
disabled={!canUpload || !items.length || isAdding || isFolderLoading}
onClick={handleUpload}
>
{isAdding ? t("Adding to Upload Queue") : t("Start Upload")}
</Button>
</div>
+37 -17
View File
@@ -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<VersionRow[]>([])
@@ -167,25 +169,43 @@ export function ObjectVersions({
{
id: "actions",
header: () => t("Action"),
cell: ({ row }) => (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => onPreview(row.original.VersionId ?? "")}>
<RiEyeLine className="size-4" />
{t("Preview")}
</Button>
<Button variant="outline" size="sm" onClick={() => downloadVersion(row.original)}>
<RiDownloadCloud2Line className="size-4" />
{t("Download")}
</Button>
<Button variant="destructive" size="sm" className="text-white" onClick={() => deleteVersion(row.original)}>
<RiDeleteBin5Line className="size-4" />
{t("Delete")}
</Button>
</div>
),
cell: ({ row }) => {
const objectContext = {
bucket: bucketName,
objectKey,
}
return (
<div className="flex gap-2">
{canCapability("objects.version.view", objectContext) ? (
<Button variant="outline" size="sm" onClick={() => onPreview(row.original.VersionId ?? "")}>
<RiEyeLine className="size-4" />
{t("Preview")}
</Button>
) : null}
{canCapability("objects.download", objectContext) ? (
<Button variant="outline" size="sm" onClick={() => downloadVersion(row.original)}>
<RiDownloadCloud2Line className="size-4" />
{t("Download")}
</Button>
) : null}
{canCapability("objects.delete", objectContext) ? (
<Button
variant="destructive"
size="sm"
className="text-white"
onClick={() => deleteVersion(row.original)}
>
<RiDeleteBin5Line className="size-4" />
{t("Delete")}
</Button>
) : null}
</div>
)
},
},
],
[t, onPreview, copyVersionId, downloadVersion, deleteVersion],
[t, onPreview, copyVersionId, downloadVersion, deleteVersion, canCapability, bucketName, objectKey],
)
const { table } = useDataTable<VersionRow>({
+67 -42
View File
@@ -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
<div className="space-y-4 max-h-[80vh] overflow-auto px-2 -mx-2">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-4">
<TabsList className="justify-start overflow-x-auto">
<TabsTrigger value="account">{t("Account")}</TabsTrigger>
<TabsTrigger value="groups">{t("Groups")}</TabsTrigger>
<TabsTrigger value="policy">{t("Policies")}</TabsTrigger>
{canEditAccount ? <TabsTrigger value="account">{t("Account")}</TabsTrigger> : null}
{canAssignGroups ? <TabsTrigger value="groups">{t("Groups")}</TabsTrigger> : null}
{canEditPolicies ? <TabsTrigger value="policy">{t("Policies")}</TabsTrigger> : null}
</TabsList>
<TabsContent value="account" className="mt-0 space-y-4">
<div className="flex items-center justify-start gap-2 rounded-md border px-3 py-2">
<span className="text-sm text-muted-foreground">{t("Status")}</span>
<Switch checked={statusBoolean} onCheckedChange={handleStatusChange} disabled={loading || submitting} />
</div>
<UserEditSecretKey
value={secretKey}
error={errors.secretKey}
disabled={loading || submitting}
onChange={setSecretKey}
/>
</TabsContent>
{canEditAccount ? (
<TabsContent value="account" className="mt-0 space-y-4">
<div className="flex items-center justify-start gap-2 rounded-md border px-3 py-2">
<span className="text-sm text-muted-foreground">{t("Status")}</span>
<Switch
checked={statusBoolean}
onCheckedChange={handleStatusChange}
disabled={loading || submitting}
/>
</div>
<UserEditSecretKey
value={secretKey}
error={errors.secretKey}
disabled={loading || submitting}
onChange={setSecretKey}
/>
</TabsContent>
) : null}
<TabsContent value="groups" className="mt-0">
<UserEditGroups
value={user.memberOf}
options={groupsList}
disabled={loading || submitting}
onChange={(memberOf) => setUser((prev) => ({ ...prev, memberOf }))}
/>
</TabsContent>
{canAssignGroups ? (
<TabsContent value="groups" className="mt-0">
<UserEditGroups
value={user.memberOf}
options={groupsList}
disabled={loading || submitting}
onChange={(memberOf) => setUser((prev) => ({ ...prev, memberOf }))}
/>
</TabsContent>
) : null}
<TabsContent value="policy" className="mt-0">
<UserEditPolicies
value={user.policy}
options={policiesList}
disabled={loading || submitting}
onChange={(policy) => setUser((prev) => ({ ...prev, policy }))}
/>
</TabsContent>
{canEditPolicies ? (
<TabsContent value="policy" className="mt-0">
<UserEditPolicies
value={user.policy}
options={policiesList}
disabled={loading || submitting}
onChange={(policy) => setUser((prev) => ({ ...prev, policy }))}
/>
</TabsContent>
) : null}
</Tabs>
</div>
@@ -261,7 +286,7 @@ export function UserEditForm({ open, onOpenChange, row, onSuccess }: UserEditFor
<Button variant="outline" onClick={closeModal} disabled={submitting}>
{t("Cancel")}
</Button>
<Button onClick={submitForm} disabled={loading || submitting}>
<Button onClick={submitForm} disabled={loading || submitting || availableTabs.length === 0}>
{submitting ? <Spinner className="size-4" /> : null}
<span>{t("Submit")}</span>
</Button>
+13
View File
@@ -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<void>
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,
+94 -64
View File
@@ -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<string, string> = {
"*": "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<string, string[]> = {
@@ -51,13 +73,11 @@ const IMPLIED_SCOPES: Record<string, string[]> = {
"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<string, string[]> = {
"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
})
+145
View File
@@ -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<ConsoleCapability, CapabilityRequirement[]> = {
"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))
})
}
+36
View File
@@ -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}*`
}