From a604d2f472a04e386990c8693afeda5ee4d8025a Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 12 Feb 2026 13:32:51 +0800 Subject: [PATCH 1/2] feat:show objects count --- app/(dashboard)/browser/page.tsx | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/app/(dashboard)/browser/page.tsx b/app/(dashboard)/browser/page.tsx index e4903bb..4bf5b8e 100644 --- a/app/(dashboard)/browser/page.tsx +++ b/app/(dashboard)/browser/page.tsx @@ -51,7 +51,7 @@ function BrowserBucketsPage() { const loadBucketUsage = useCallback( async (fetchId: number, bucketNames: string[]) => { - if (!isAdmin || bucketNames.length === 0) { + if (bucketNames.length === 0) { setUsageLoading(false) return } @@ -81,7 +81,7 @@ function BrowserBucketsPage() { } } }, - [getDataUsageInfo, isAdmin], + [getDataUsageInfo], ) const fetchBuckets = useCallback( @@ -111,15 +111,12 @@ function BrowserBucketsPage() { setData(buckets) setPending(false) - if (isAdmin) { - setUsageLoading(true) - void loadBucketUsage( - fetchId, - buckets.map((bucket) => bucket.Name), - ) - } else { - setUsageLoading(false) - } + setUsageLoading(true) + void loadBucketUsage( + fetchId, + buckets.map((bucket) => bucket.Name), + ) + } catch (error) { if (fetchId !== fetchIdRef.current) return console.error("Failed to fetch buckets:", error) @@ -130,7 +127,7 @@ function BrowserBucketsPage() { } } }, - [isAdmin, listBuckets, loadBucketUsage], + [listBuckets, loadBucketUsage], ) useEffect(() => { @@ -165,7 +162,6 @@ function BrowserBucketsPage() { }, ] - if (isAdmin) { baseColumns.push( { header: () => t("Object Count"), @@ -186,8 +182,7 @@ function BrowserBucketsPage() { row.original.Size ?? (usageLoading ? : "--"), }, ) - } - + baseColumns.push({ id: "actions", header: () => t("Actions"), From db421a34dfbf58fda4398214e262fd0c262bb9bf Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 12 Feb 2026 17:08:39 +0800 Subject: [PATCH 2/2] feat: dataobject count sub account permissions --- app/(dashboard)/browser/page.tsx | 10 ++- hooks/use-system.ts | 2 +- lib/api-client.ts | 12 ++++ lib/console-policy-parser.ts | 117 +++++++++++++++++++++++++++++-- 4 files changed, 132 insertions(+), 9 deletions(-) diff --git a/app/(dashboard)/browser/page.tsx b/app/(dashboard)/browser/page.tsx index 4bf5b8e..ee4cc7e 100644 --- a/app/(dashboard)/browser/page.tsx +++ b/app/(dashboard)/browser/page.tsx @@ -57,8 +57,15 @@ 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 + + // If usage is undefined (e.g., 403 error), don't update the data + // This allows the table to show "--" instead of "0" and "0 B" + if (!usage) { + return + } + const bucketUsage = usage?.buckets_usage ?? {} setData((prev) => @@ -75,6 +82,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) diff --git a/hooks/use-system.ts b/hooks/use-system.ts index 363008f..352350e 100644 --- a/hooks/use-system.ts +++ b/hooks/use-system.ts @@ -15,7 +15,7 @@ export function useSystem() { }, [api]) const getDataUsageInfo = useCallback(async () => { - return api.get("/datausageinfo") + return api.get("/datausageinfo", { suppress403Redirect: true }) }, [api]) const getSystemMetrics = useCallback(async () => { diff --git a/lib/api-client.ts b/lib/api-client.ts index ba1199e..c606c57 100644 --- a/lib/api-client.ts +++ b/lib/api-client.ts @@ -16,6 +16,11 @@ interface RequestOptions { body?: unknown params?: Record 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>() @@ -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 = "" diff --git a/lib/console-policy-parser.ts b/lib/console-policy-parser.ts index 8a4fa59..ae7371f 100644 --- a/lib/console-policy-parser.ts +++ b/lib/console-policy-parser.ts @@ -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,33 @@ 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) + * @param requestAction - The action to check + * @returns true if the action matches + */ +function matchAction(policyActions: string[] | undefined, requestAction: string): boolean { + // Empty array or undefined means match all actions + if (!policyActions || 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 = { [CONSOLE_SCOPES.VIEW_BROWSER]: [ "s3:ListAllMyBuckets", @@ -88,6 +112,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 +129,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) : true + } + 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) : true + } + + 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 } }