Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions app/(dashboard)/browser/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const { isAdmin } = useAuth()

Check warning on line 41 in app/(dashboard)/browser/page.tsx

View workflow job for this annotation

GitHub Actions / 🔍 Code Quality

'isAdmin' is assigned a value but never used

Check warning on line 41 in app/(dashboard)/browser/page.tsx

View workflow job for this annotation

GitHub Actions / 💅 Code Formatting

'isAdmin' is assigned a value but never used

Check warning on line 41 in app/(dashboard)/browser/page.tsx

View workflow job for this annotation

GitHub Actions / 💅 Code Formatting

'isAdmin' is assigned a value but never used
const { listBuckets, deleteBucket } = useBucket()
const { getDataUsageInfo } = useSystem()

Expand All @@ -51,14 +51,21 @@

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) =>
Expand All @@ -75,13 +82,14 @@
)
} 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(
Expand Down Expand Up @@ -111,15 +119,12 @@
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)
Expand All @@ -130,7 +135,7 @@
}
}
},
[isAdmin, listBuckets, loadBucketUsage],
[listBuckets, loadBucketUsage],
)

useEffect(() => {
Expand Down Expand Up @@ -165,7 +170,6 @@
},
]

if (isAdmin) {
baseColumns.push(
{
header: () => t("Object Count"),
Expand All @@ -186,8 +190,7 @@
row.original.Size ?? (usageLoading ? <Spinner className="size-3 text-muted-foreground" /> : "--"),
},
)
}


baseColumns.push({
id: "actions",
header: () => t("Actions"),
Expand Down
2 changes: 1 addition & 1 deletion hooks/use-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
12 changes: 12 additions & 0 deletions lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>>()
Expand Down Expand Up @@ -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 = ""
Expand Down
117 changes: 110 additions & 7 deletions lib/console-policy-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { CONSOLE_SCOPES } from "./console-permissions"

export interface ConsoleStatement {
Effect: "Allow" | "Deny"
Action: string[]
Action?: string[]
NotAction?: string[]
Resource?: string[]
}

Expand All @@ -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<string, string[]> = {
[CONSOLE_SCOPES.VIEW_BROWSER]: [
"s3:ListAllMyBuckets",
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
}

Expand Down