diff --git a/app/(dashboard)/browser/page.tsx b/app/(dashboard)/browser/page.tsx
index e4903bb..ee4cc7e 100644
--- a/app/(dashboard)/browser/page.tsx
+++ b/app/(dashboard)/browser/page.tsx
@@ -51,14 +51,21 @@ function BrowserBucketsPage() {
const loadBucketUsage = useCallback(
async (fetchId: number, bucketNames: string[]) => {
- if (!isAdmin || bucketNames.length === 0) {
+ if (bucketNames.length === 0) {
setUsageLoading(false)
return
}
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,13 +82,14 @@ function BrowserBucketsPage() {
)
} catch {
if (fetchId !== fetchIdRef.current) return
+ // On error, don't update the data - keep showing "--"
} finally {
if (fetchId === fetchIdRef.current) {
setUsageLoading(false)
}
}
},
- [getDataUsageInfo, isAdmin],
+ [getDataUsageInfo],
)
const fetchBuckets = useCallback(
@@ -111,15 +119,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 +135,7 @@ function BrowserBucketsPage() {
}
}
},
- [isAdmin, listBuckets, loadBucketUsage],
+ [listBuckets, loadBucketUsage],
)
useEffect(() => {
@@ -165,7 +170,6 @@ function BrowserBucketsPage() {
},
]
- if (isAdmin) {
baseColumns.push(
{
header: () => t("Object Count"),
@@ -186,8 +190,7 @@ function BrowserBucketsPage() {
row.original.Size ?? (usageLoading ? : "--"),
},
)
- }
-
+
baseColumns.push({
id: "actions",
header: () => t("Actions"),
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
}
}