mirror of
https://github.com/rustfs/console.git
synced 2026-08-30 17:14:47 +08:00
Objectcount (#49)
* feat: dataobject count sub account permissions * Update lib/console-policy-parser.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update hooks/use-system.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: resolve lint errors (no-explicit-any, no-unused-vars) --------- Co-authored-by: 安正超 <anzhengchao@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -16,7 +16,6 @@ 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 { useAuth } from "@/contexts/auth-context"
|
||||
import { useDialog } from "@/lib/feedback/dialog"
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { niceBytes } from "@/lib/functions"
|
||||
@@ -39,7 +38,6 @@ function BrowserBucketsPage() {
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { isAdmin } = useAuth()
|
||||
const { listBuckets, deleteBucket } = useBucket()
|
||||
const { getDataUsageInfo } = useSystem()
|
||||
|
||||
@@ -58,8 +56,14 @@ function BrowserBucketsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap }
|
||||
const usage = (await getDataUsageInfo()) as { buckets_usage?: BucketUsageMap } | undefined
|
||||
if (fetchId !== fetchIdRef.current) return
|
||||
|
||||
// getDataUsageInfo returns undefined on 403; don't update data so table shows "--"
|
||||
if (!usage) {
|
||||
return
|
||||
}
|
||||
|
||||
const bucketUsage = usage?.buckets_usage ?? {}
|
||||
|
||||
setData((prev) =>
|
||||
@@ -77,6 +81,7 @@ function BrowserBucketsPage() {
|
||||
)
|
||||
} catch {
|
||||
if (fetchId !== fetchIdRef.current) return
|
||||
// On error, don't update the data - keep showing "--"
|
||||
} finally {
|
||||
if (fetchId === fetchIdRef.current) {
|
||||
setUsageLoading(false)
|
||||
|
||||
@@ -44,7 +44,6 @@ export function ObjectInfo({
|
||||
objectKey,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRefresh,
|
||||
autoPreview = false,
|
||||
onPreviewChange,
|
||||
}: ObjectInfoProps) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AwsClient } from "@/lib/aws4fetch"
|
||||
import { ApiErrorHandler } from "@/lib/api-error-handler"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { configManager } from "@/lib/config"
|
||||
import { getLoginRoute, buildRoute } from "@/lib/routes"
|
||||
|
||||
interface ApiContextValue {
|
||||
api: ApiClient | null
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation"
|
||||
import { S3Client } from "@aws-sdk/client-s3"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { configManager } from "@/lib/config"
|
||||
import { getLoginRoute } from "@/lib/routes"
|
||||
import type { SiteConfig } from "@/types/config"
|
||||
|
||||
interface S3Response {
|
||||
|
||||
+11
-1
@@ -15,7 +15,17 @@ export function useSystem() {
|
||||
}, [api])
|
||||
|
||||
const getDataUsageInfo = useCallback(async () => {
|
||||
return api.get("/datausageinfo")
|
||||
try {
|
||||
return await api.get("/datausageinfo", { suppress403Redirect: true })
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ?? (error as { response?: { status?: number } })?.response?.status
|
||||
if (status === 403) {
|
||||
// Preserve previous behavior: treat 403 as "no data" instead of rejecting.
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}, [api])
|
||||
|
||||
const getSystemMetrics = useCallback(async () => {
|
||||
|
||||
@@ -16,6 +16,11 @@ interface RequestOptions {
|
||||
body?: unknown
|
||||
params?: Record<string, string>
|
||||
dedupe?: boolean
|
||||
/**
|
||||
* If true, 403 errors will throw an error instead of triggering global error handler
|
||||
* This allows components to handle permission errors gracefully
|
||||
*/
|
||||
suppress403Redirect?: boolean
|
||||
}
|
||||
|
||||
const inflightGetRequests = new Map<string, Promise<unknown>>()
|
||||
@@ -78,6 +83,13 @@ export class ApiClient {
|
||||
return
|
||||
}
|
||||
if (response.status === 403) {
|
||||
// If suppress403Redirect is true, throw error instead of triggering global handler
|
||||
// This allows components to handle permission errors gracefully
|
||||
if (options.suppress403Redirect) {
|
||||
const errorMsg = await parseApiError(response)
|
||||
throw new Error(errorMsg)
|
||||
}
|
||||
|
||||
try {
|
||||
const cloned = response.clone()
|
||||
let codeText = ""
|
||||
|
||||
@@ -3,7 +3,8 @@ import { CONSOLE_SCOPES } from "./console-permissions"
|
||||
|
||||
export interface ConsoleStatement {
|
||||
Effect: "Allow" | "Deny"
|
||||
Action: string[]
|
||||
Action?: string[]
|
||||
NotAction?: string[]
|
||||
Resource?: string[]
|
||||
}
|
||||
|
||||
@@ -12,10 +13,36 @@ export interface ConsolePolicy {
|
||||
Statement: ConsoleStatement[]
|
||||
}
|
||||
|
||||
function matchAction(policyActions: string[], requestAction: string): boolean {
|
||||
/**
|
||||
* Check if an action matches the policy actions
|
||||
* @param policyActions - Array of action patterns (empty array means match all, undefined means no match)
|
||||
* @param requestAction - The action to check
|
||||
* @returns true if the action matches
|
||||
*/
|
||||
function matchAction(policyActions: string[] | undefined, requestAction: string): boolean {
|
||||
// Undefined means no match; explicit empty array means match all actions
|
||||
if (policyActions === undefined) {
|
||||
return false
|
||||
}
|
||||
if (policyActions.length === 0) {
|
||||
return true
|
||||
}
|
||||
return policyActions.some((pattern) => resourceMatch(pattern, requestAction))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action matches the NotAction patterns
|
||||
* @param notActions - Array of action patterns to exclude
|
||||
* @param requestAction - The action to check
|
||||
* @returns true if the action should be excluded
|
||||
*/
|
||||
function matchNotAction(notActions: string[] | undefined, requestAction: string): boolean {
|
||||
if (!notActions || notActions.length === 0) {
|
||||
return false
|
||||
}
|
||||
return notActions.some((pattern) => resourceMatch(pattern, requestAction))
|
||||
}
|
||||
|
||||
const IMPLIED_SCOPES: Record<string, string[]> = {
|
||||
[CONSOLE_SCOPES.VIEW_BROWSER]: [
|
||||
"s3:ListAllMyBuckets",
|
||||
@@ -88,6 +115,13 @@ function matchResource(policyResources: string[] | undefined, requestResource: s
|
||||
return policyResources.some((pattern) => resourceMatch(pattern, requestResource))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action is a console scope (starts with "console:" or is "consoleAdmin")
|
||||
*/
|
||||
function isConsoleScope(action: string): boolean {
|
||||
return action.startsWith("console:") || action === CONSOLE_SCOPES.CONSOLE_ADMIN
|
||||
}
|
||||
|
||||
export function hasConsolePermission(
|
||||
policy: ConsolePolicy | ConsoleStatement[] | undefined,
|
||||
action: string,
|
||||
@@ -98,28 +132,100 @@ export function hasConsolePermission(
|
||||
const statements = Array.isArray(policy) ? policy : policy.Statement || []
|
||||
if (statements.length === 0) return false
|
||||
|
||||
const denied = statements.some(
|
||||
(s) => s.Effect === "Deny" && matchAction(s.Action, action) && matchResource(s.Resource, resource),
|
||||
)
|
||||
// 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
|
||||
|
||||
// If NotAction is present, deny applies to all actions EXCEPT those in NotAction
|
||||
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 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
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
if (denied) return false
|
||||
|
||||
// Check Allow statements
|
||||
const allowed = statements.some((s) => {
|
||||
if (s.Effect !== "Allow") return false
|
||||
|
||||
// Handle NotAction: allow all actions EXCEPT those in NotAction
|
||||
if (s.NotAction && s.NotAction.length > 0) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Action matches Action list, now check NotAction exclusion
|
||||
if (matchNotAction(s.NotAction, action)) {
|
||||
return false // Action is excluded by NotAction
|
||||
}
|
||||
} else {
|
||||
// Only NotAction present: allow all actions except those in NotAction
|
||||
if (matchNotAction(s.NotAction, action)) {
|
||||
return false // Action is excluded
|
||||
}
|
||||
}
|
||||
// Action is allowed (matches Action if present, and not in NotAction), check resource
|
||||
return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true
|
||||
}
|
||||
|
||||
// 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) && matchResource(s.Resource, resource)
|
||||
actionMatch || adminMatch || wildcardMatch || adminStarMatch
|
||||
? shouldCheckResource(s)
|
||||
? matchResource(s.Resource, resource)
|
||||
: true
|
||||
: false
|
||||
if (explicitMatch) return true
|
||||
|
||||
const impliedActions = IMPLIED_SCOPES[action]
|
||||
if (impliedActions) {
|
||||
if (impliedActions.some((implied) => matchAction(s.Action, implied))) {
|
||||
return true
|
||||
return shouldCheckResource(s) ? matchResource(s.Resource, resource) : true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user