feat(oidc): add fine grained access control (#91)

* feat(access-control): fine grain tokens

* fix(docs): clean wording

* test(access-control): add tests for team extraction from access tokens and bucket info permissions

* test(vocabulary): add test for ExpandGlob function to reject non-glob patterns

* feat(helm): add multi-user access control documentation and schema support
This commit is contained in:
Noste
2026-07-11 17:57:28 +02:00
committed by GitHub
parent 3098e474f2
commit 0d804fbd39
49 changed files with 3224 additions and 234 deletions
@@ -12,6 +12,7 @@ import {
import { FolderIcon, Globe, Loader2, MoreVertical, Search, Settings, Trash2 } from 'lucide-react';
import { formatBytes } from '@/lib/file-utils';
import { formatDate } from '@/lib/utils';
import { useBucketCan } from '@/hooks/usePermissions';
import type { Bucket } from '@/types';
interface BucketListViewProps {
@@ -35,6 +36,7 @@ export function BucketListView({
onDeleteBucket,
onWebsiteSettings,
}: BucketListViewProps) {
const canBucket = useBucketCan();
const filteredBuckets = buckets.filter((bucket) =>
bucket.name.toLowerCase().includes(searchQuery.toLowerCase())
);
@@ -118,31 +120,39 @@ export function BucketListView({
<FolderIcon className="h-4 w-4" />
View Objects
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onOpenSettings(bucket);
}}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onWebsiteSettings(bucket);
}}>
<Globe className="h-4 w-4" />
Website Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBucket(bucket);
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
{canBucket(bucket, 'bucket.update') && (
<>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onOpenSettings(bucket);
}}>
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
onWebsiteSettings(bucket);
}}>
<Globe className="h-4 w-4" />
Website Settings
</DropdownMenuItem>
</>
)}
{canBucket(bucket, 'bucket.delete') && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBucket(bucket);
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@@ -25,11 +25,11 @@ interface ObjectBrowserViewProps {
onDeepSearchChange: (enabled: boolean) => void;
onNavigateToFolder: (path: string) => void;
onBackToBuckets: () => void;
onUploadFiles: (files: File[]) => Promise<boolean>;
onUploadFiles?: (files: File[]) => Promise<boolean>;
uploadTasks: UploadTask[];
onDeleteObject: (key: string) => Promise<boolean>;
onDeleteMultipleObjects: (keys: string[]) => Promise<boolean>;
onCreateDirectory: (name: string) => Promise<boolean>;
onDeleteObject?: (key: string) => Promise<boolean>;
onDeleteMultipleObjects?: (keys: string[]) => Promise<boolean>;
onCreateDirectory?: (name: string) => Promise<boolean>;
onRefresh: () => Promise<void>;
onPageChange: (token?: string) => void;
onItemsPerPageChange: (count: number) => void;
@@ -75,6 +75,8 @@ export function ObjectBrowserView({
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles, _fileRejections, event) => {
if (!onUploadFiles) return;
// Get files with their full paths from DataTransferItems API
const filesWithPaths: File[] = [];
@@ -101,6 +103,7 @@ export function ObjectBrowserView({
setShowUploadZone(false);
},
noClick: true,
disabled: !onUploadFiles,
});
// Helper function to traverse file/directory tree
@@ -153,13 +156,14 @@ export function ObjectBrowserView({
};
const handleBulkDeleteFiles = async () => {
if (selectedFileKeys.size === 0) return;
if (!onDeleteMultipleObjects || selectedFileKeys.size === 0) return;
await onDeleteMultipleObjects(Array.from(selectedFileKeys));
setSelectedFileKeys(new Set());
};
const handleDeleteObject = async (key: string): Promise<boolean> => {
if (!onDeleteObject) return false;
const success = await onDeleteObject(key);
if (success) {
setDeleteObjectDialogOpen(false);
@@ -169,6 +173,7 @@ export function ObjectBrowserView({
};
const uploadFiles = async (files: File[]) => {
if (!onUploadFiles) return;
await onUploadFiles(files);
setShowUploadZone(false);
};
@@ -232,7 +237,7 @@ export function ObjectBrowserView({
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
{selectedFileKeys.size > 0 && (
{onDeleteMultipleObjects && selectedFileKeys.size > 0 && (
<Button
onClick={handleBulkDeleteFiles}
title={`Delete ${selectedFileKeys.size} selected file(s)`}
@@ -242,14 +247,18 @@ export function ObjectBrowserView({
Delete {selectedFileKeys.size} file{selectedFileKeys.size !== 1 ? 's' : ''}
</Button>
)}
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Upload</span>
</Button>
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
<FolderPlus className="h-4 w-4" />
<span className="hidden sm:inline">Add Directory</span>
</Button>
{onUploadFiles && (
<Button variant="secondary" onClick={() => setShowUploadZone(!showUploadZone)} className="flex-1 sm:flex-initial">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Upload</span>
</Button>
)}
{onCreateDirectory && (
<Button onClick={() => setCreateDirDialogOpen(true)} className="flex-1 sm:flex-initial">
<FolderPlus className="h-4 w-4" />
<span className="hidden sm:inline">Add Directory</span>
</Button>
)}
<Button variant="secondary" size="icon" onClick={onRefresh} title="Refresh" disabled={isRefreshing}>
<RotateCwIcon className={`h-4 w-4 transition-transform duration-500 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
@@ -257,7 +266,7 @@ export function ObjectBrowserView({
</div>
{/* Upload Zone */}
{showUploadZone && uploadTasks.length === 0 && (
{onUploadFiles && showUploadZone && uploadTasks.length === 0 && (
<div className="border rounded-lg p-6 bg-muted/30 space-y-4">
<div className="flex gap-6">
<div className="flex-shrink-0 flex items-center justify-center">
@@ -384,10 +393,10 @@ export function ObjectBrowserView({
nextContinuationToken={nextContinuationToken}
itemsPerPage={itemsPerPage}
onNavigateToFolder={onNavigateToFolder}
onDeleteObject={(obj) => {
onDeleteObject={onDeleteObject ? (obj) => {
setSelectedObject(obj);
setDeleteObjectDialogOpen(true);
}}
} : undefined}
onToggleFileSelection={handleToggleFileSelection}
onSelectAllFiles={handleSelectAllFiles}
onPageChange={onPageChange}
@@ -399,12 +408,14 @@ export function ObjectBrowserView({
</div>
{/* Create Directory Dialog */}
<CreateDirectoryDialog
open={createDirDialogOpen}
onOpenChange={setCreateDirDialogOpen}
currentPath={currentPath}
onCreateDirectory={onCreateDirectory}
/>
{onCreateDirectory && (
<CreateDirectoryDialog
open={createDirDialogOpen}
onOpenChange={setCreateDirDialogOpen}
currentPath={currentPath}
onCreateDirectory={onCreateDirectory}
/>
)}
{/* Delete Object Dialog */}
<DeleteObjectDialog
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { objectsApi } from '@/lib/api';
import { useBuckets } from '@/hooks/useApi';
import { useBucketCan } from '@/hooks/usePermissions';
import type { ObjectMetadata } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -36,6 +38,11 @@ export function ObjectDetailsView() {
const { bucketName, '*': encodedObjectKey } = useParams();
const objectKey = encodedObjectKey ? decodeURIComponent(encodedObjectKey) : undefined;
const { data: buckets = [] } = useBuckets();
const bucket = buckets.find((b) => b.name === bucketName);
const canBucket = useBucketCan();
const canDelete = canBucket(bucket, 'object.delete');
const [metadata, setMetadata] = useState<ObjectMetadata | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -161,9 +168,11 @@ export function ObjectDetailsView() {
<Button variant="secondary" onClick={handleDownload}>
<Download className="h-4 w-4" /> Download
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
{canDelete && (
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
<Trash2 className="h-4 w-4" /> Delete
</Button>
)}
</div>
</section>
@@ -31,7 +31,7 @@ interface ObjectsTableProps {
nextContinuationToken?: string;
itemsPerPage: number;
onNavigateToFolder: (key: string) => void;
onDeleteObject: (object: S3Object) => void;
onDeleteObject?: (object: S3Object) => void;
onToggleFileSelection: (key: string) => void;
onSelectAllFiles: () => void;
onPageChange: (token?: string) => void;
@@ -66,6 +66,7 @@ export function ObjectsTable({
initialItemsPerPage,
}: ObjectsTableProps) {
const navigate = useNavigate();
const canDelete = Boolean(onDeleteObject);
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
// Store tokens for each page: [undefined (page 1), token1 (page 2), token2 (page 3), ...]
@@ -214,16 +215,18 @@ export function ObjectsTable({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">
<Checkbox
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
/>
</TableHead>
{canDelete && (
<TableHead className="w-[50px]">
<Checkbox
checked={
filteredObjects.filter(obj => !obj.isFolder).length > 0 &&
selectedFileKeys.size === filteredObjects.filter(obj => !obj.isFolder).length
}
onCheckedChange={onSelectAllFiles}
aria-label="Select all files"
/>
</TableHead>
)}
<TableHead
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleSort('name')}
@@ -250,7 +253,7 @@ export function ObjectsTable({
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12">
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12">
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading objects...</span>
@@ -259,7 +262,7 @@ export function ObjectsTable({
</TableRow>
) : filteredObjects.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-muted-foreground">
<TableCell colSpan={canDelete ? 7 : 6} className="text-center py-12 text-muted-foreground">
{searchQuery
? 'No objects found matching your search'
: isDragActive
@@ -270,22 +273,24 @@ export function ObjectsTable({
) : (
pageObjects.map((obj) => (
<TableRow key={obj.key}>
<TableCell className="w-[50px]">
{obj.isFolder ? (
<Checkbox
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
/>
) : (
<Checkbox
checked={selectedFileKeys.has(obj.key)}
onCheckedChange={() => onToggleFileSelection(obj.key)}
aria-label={`Select file ${obj.key}`}
/>
)}
</TableCell>
{canDelete && (
<TableCell className="w-[50px]">
{obj.isFolder ? (
<Checkbox
disabled
checked={false}
className="opacity-50 cursor-not-allowed bg-muted"
aria-label="Folders cannot be selected"
/>
) : (
<Checkbox
checked={selectedFileKeys.has(obj.key)}
onCheckedChange={() => onToggleFileSelection(obj.key)}
aria-label={`Select file ${obj.key}`}
/>
)}
</TableCell>
)}
<TableCell>
<div className="flex items-center gap-2">
{obj.isFolder ? (
@@ -390,14 +395,18 @@ export function ObjectsTable({
<Download className="h-4 w-4" />
Download
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteObject(obj)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
{onDeleteObject && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDeleteObject(obj)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}