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:
GatewayJ
2026-02-27 20:41:34 +08:00
committed by GitHub
parent 85d73ad6d6
commit f84d0dccd4
7 changed files with 144 additions and 14 deletions
+8 -3
View File
@@ -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)
-1
View File
@@ -44,7 +44,6 @@ export function ObjectInfo({
objectKey,
open,
onOpenChange,
onRefresh,
autoPreview = false,
onPreviewChange,
}: ObjectInfoProps) {
-1
View File
@@ -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
-1
View File
@@ -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
View File
@@ -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 () => {
+12
View File
@@ -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 = ""
+113 -7
View File
@@ -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
}
}