From e67973dc5314ad56833849298cdb017bede58a57 Mon Sep 17 00:00:00 2001 From: Chris Lu Date: Tue, 6 Jan 2026 21:53:28 -0800 Subject: [PATCH] Support Policy Attachment for Object Store Users (#7981) * Implement Policy Attachment support for Object Store Users - Added policy_names field to iam.proto and regenerated protos. - Updated S3 API and IAM integration to support direct policy evaluation for users. - Enhanced Admin UI to allow attaching policies to users via modals. - Renamed 'policies' to 'policy_names' to clarify that it stores identifiers. - Fixed syntax error in user_management.go. * Fix policy dropdown not populating The API returns {policies: [...]} but JavaScript was treating response as direct array. Updated loadPolicies() to correctly access data.policies property. * Add null safety checks for policy dropdowns Added checks to prevent "undefined" errors when: - Policy select elements don't exist - Policy dropdowns haven't loaded yet - User is being edited before policies are loaded * Fix policy dropdown by using correct JSON field name JSON response has lowercase 'name' field but JavaScript was accessing 'Name'. Changed policy.Name to policy.name to match the IAMPolicy JSON structure. * Fix policy names not being saved on user update Changed condition from len(req.PolicyNames) > 0 to req.PolicyNames != nil to ensure policy names are always updated when present in the request, even if it's an empty array (to allow clearing policies). * Add debug logging for policy names update flow Added console.log in frontend and glog in backend to trace policy_names data through the update process. * Temporarily disable auto-reload for debugging Commented out window.location.reload() so console logs are visible when updating a user. * Add detailed debug logging and alert for policy selection Added console.log for each step and an alert to show policy_names value to help diagnose why it's not being included in the request. * Regenerate templ files for object_store_users Ran templ generate to ensure _templ.go files are up to date with the latest .templ changes including debug logging. * Remove debug logging and restore normal functionality Cleaned up temporary debug code (console.log and alert statements) and re-enabled automatic page reload after user update. * Add step-by-step alert debugging for policy update Added 5 alert checkpoints to trace policy data through the update flow: 1. Check if policiesSelect element exists 2. Show selected policy values 3. Show userData.policy_names 4. Show full request body 5. Confirm server response Temporarily disabled auto-reload to see alerts. * Add version check alert on page load Added alert on DOMContentLoaded to verify new JavaScript is being executed and not cached by the browser. * Compile templates using make Ran make to compile all template files and install the weed binary. * Add button click detection and make handleUpdateUser global - Added inline alert on button click to verify click is detected - Made handleUpdateUser a window-level function to ensure it's accessible - Added alert at start of handleUpdateUser function * Fix handleUpdateUser scope issue - remove duplicate definition Removed duplicate function definition that was inside DOMContentLoaded. Now handleUpdateUser is defined only once in global scope (line 383) making it accessible when button onclick fires. * Remove all duplicate handleUpdateUser definitions Now handleUpdateUser is defined only once at the very top of the script block (line 352), before DOMContentLoaded, ensuring it's available when the button onclick fires. * Add function existence check and error catching Added alerts to check if handleUpdateUser is defined and wrapped the function call in try-catch to capture any JavaScript errors. Also added console.log statements to verify function definition. * Simplify handleUpdateUser to non-async for testing Removed async/await and added early return to test if function can be called at all. This will help identify if async is causing the issue. * Add cache-control headers to prevent browser caching Added no-cache headers to ShowObjectStoreUsers handler to prevent aggressive browser caching of inline JavaScript in the HTML page. * Fix syntax error - make handleUpdateUser async Changed function back to async to fix 'await is only valid in async functions' error. The cache-control headers are working - browser is now loading new code. * Update version check to v3 to verify cache busting Changed version alert to 'v3 - WITH EARLY RETURN' to confirm the new code with early return statement is being loaded. * Remove all debug code - clean implementation Removed all alerts, console.logs, and test code. Implemented clean policy update functionality with proper error handling. * Add ETag header for cache-busting and update walkthrough * Fix policy pre-selection in Edit User modal - Updated admin.js editUser function to pre-select policies - Root cause: duplicate editUser in admin.js overwrote inline version - Added policy pre-selection logic to match inline template - Verified working in browser: policies now pre-select correctly * Fix policy persistence in handleUpdateUser - Added policy_names field to userData payload in handleUpdateUser - Policies were being lost because handleUpdateUser only sent email and actions - Now collects selected policies from editPolicies dropdown - Verified working: policies persist correctly across updates * Fix XSS vulnerability in access keys display - Escape HTML in access key display using escapeHtml utility - Replace inline onclick handlers with data attributes - Add event delegation for delete access key buttons - Prevents script injection via malicious access key values * Fix additional XSS vulnerabilities in user details display - Escape HTML in actions badges (line 626) - Escape HTML in policy_names badges (line 636) - Prevents script injection via malicious action or policy names * Fix XSS vulnerability in loadPolicies function - Replace innerHTML string concatenation with DOM API - Use createElement and textContent for safe policy name insertion - Prevents script injection via malicious policy names - Apply same pattern to both create and edit select elements * Remove debug logging from UpdateObjectStoreUser - Removed glog.V(0) debug statements - Clean up temporary debugging code before production * Remove duplicate handleUpdateUser function - Removed inline handleUpdateUser that duplicated admin.js logic - Removed debug console.log statement - admin.js version is now the single source of truth - Eliminates maintenance burden of keeping two versions in sync * Refine user management and address code review feedback - Preserve PolicyNames in UpdateUserPolicies - Allow clearing actions in UpdateObjectStoreUser by checking for nil - Remove version comment from object_store_users.templ - Refactor loadPolicies for DRYness using cloneNode while keeping DOM API security * IAM Authorization for Static Access Keys * verified XSS Fixes in Templates * fix div --- weed/admin/dash/admin_data.go | 16 +- weed/admin/dash/user_management.go | 20 +- weed/admin/handlers/user_handlers.go | 6 + weed/admin/static/js/admin.js | 504 +++++++++--------- weed/admin/view/app/object_store_users.templ | 139 +++-- .../view/app/object_store_users_templ.go | 2 +- weed/iam/integration/iam_manager.go | 33 +- weed/pb/filer_pb/filer.pb.go | 2 +- weed/pb/filer_pb/filer_grpc.pb.go | 2 +- weed/pb/iam.proto | 1 + weed/pb/iam_pb/iam.pb.go | 13 +- weed/pb/master_pb/master.pb.go | 2 +- weed/pb/master_pb/master_grpc.pb.go | 2 +- weed/pb/mount_pb/mount.pb.go | 2 +- weed/pb/mount_pb/mount_grpc.pb.go | 2 +- weed/pb/mq_agent_pb/mq_agent.pb.go | 2 +- weed/pb/mq_agent_pb/mq_agent_grpc.pb.go | 2 +- weed/pb/mq_pb/mq_broker.pb.go | 2 +- weed/pb/mq_pb/mq_broker_grpc.pb.go | 2 +- weed/pb/remote_pb/remote.pb.go | 2 +- weed/pb/s3_pb/s3.pb.go | 2 +- weed/pb/s3_pb/s3_grpc.pb.go | 2 +- weed/pb/schema_pb/mq_schema.pb.go | 2 +- weed/pb/volume_server_pb/volume_server.pb.go | 2 +- .../volume_server_pb/volume_server_grpc.pb.go | 2 +- weed/pb/worker_pb/worker.pb.go | 2 +- weed/pb/worker_pb/worker_grpc.pb.go | 2 +- weed/s3api/auth_credentials.go | 18 +- weed/s3api/s3_iam_middleware.go | 5 +- 29 files changed, 456 insertions(+), 337 deletions(-) diff --git a/weed/admin/dash/admin_data.go b/weed/admin/dash/admin_data.go index e58087840..7a9e50964 100644 --- a/weed/admin/dash/admin_data.go +++ b/weed/admin/dash/admin_data.go @@ -37,6 +37,7 @@ type ObjectStoreUser struct { AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` Permissions []string `json:"permissions"` + PolicyNames []string `json:"policy_names"` } type ObjectStoreUsersData struct { @@ -52,11 +53,13 @@ type CreateUserRequest struct { Email string `json:"email"` Actions []string `json:"actions"` GenerateKey bool `json:"generate_key"` + PolicyNames []string `json:"policy_names"` } type UpdateUserRequest struct { - Email string `json:"email"` - Actions []string `json:"actions"` + Email string `json:"email"` + Actions []string `json:"actions"` + PolicyNames []string `json:"policy_names"` } type UpdateUserPoliciesRequest struct { @@ -70,10 +73,11 @@ type AccessKeyInfo struct { } type UserDetails struct { - Username string `json:"username"` - Email string `json:"email"` - Actions []string `json:"actions"` - AccessKeys []AccessKeyInfo `json:"access_keys"` + Username string `json:"username"` + Email string `json:"email"` + Actions []string `json:"actions"` + PolicyNames []string `json:"policy_names"` + AccessKeys []AccessKeyInfo `json:"access_keys"` } type FilerNode struct { diff --git a/weed/admin/dash/user_management.go b/weed/admin/dash/user_management.go index 3a74a675f..38e1f316d 100644 --- a/weed/admin/dash/user_management.go +++ b/weed/admin/dash/user_management.go @@ -21,8 +21,9 @@ func (s *AdminServer) CreateObjectStoreUser(req CreateUserRequest) (*ObjectStore // Create new identity newIdentity := &iam_pb.Identity{ - Name: req.Username, - Actions: req.Actions, + Name: req.Username, + Actions: req.Actions, + PolicyNames: req.PolicyNames, } // Add account if email is provided @@ -63,6 +64,7 @@ func (s *AdminServer) CreateObjectStoreUser(req CreateUserRequest) (*ObjectStore AccessKey: accessKey, SecretKey: secretKey, Permissions: req.Actions, + PolicyNames: req.PolicyNames, } return user, nil @@ -91,12 +93,17 @@ func (s *AdminServer) UpdateObjectStoreUser(username string, req UpdateUserReque Account: identity.Account, Credentials: identity.Credentials, Actions: identity.Actions, + PolicyNames: identity.PolicyNames, } // Update actions if provided - if len(req.Actions) > 0 { + if req.Actions != nil { updatedIdentity.Actions = req.Actions } + // Always update policy names when present in request (even if empty to allow clearing) + if req.PolicyNames != nil { + updatedIdentity.PolicyNames = req.PolicyNames + } // Update email if provided if req.Email != "" { @@ -120,6 +127,7 @@ func (s *AdminServer) UpdateObjectStoreUser(username string, req UpdateUserReque Username: username, Email: req.Email, Permissions: updatedIdentity.Actions, + PolicyNames: updatedIdentity.PolicyNames, } // Get first access key for display @@ -169,8 +177,9 @@ func (s *AdminServer) GetObjectStoreUserDetails(username string) (*UserDetails, } details := &UserDetails{ - Username: username, - Actions: identity.Actions, + Username: username, + Actions: identity.Actions, + PolicyNames: identity.PolicyNames, } // Set email from account if available @@ -295,6 +304,7 @@ func (s *AdminServer) UpdateUserPolicies(username string, actions []string) erro Account: identity.Account, Credentials: identity.Credentials, Actions: actions, + PolicyNames: identity.PolicyNames, } // Update user using credential manager diff --git a/weed/admin/handlers/user_handlers.go b/weed/admin/handlers/user_handlers.go index ed280a0ad..89b07bf75 100644 --- a/weed/admin/handlers/user_handlers.go +++ b/weed/admin/handlers/user_handlers.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "net/http" "time" @@ -29,7 +30,12 @@ func (h *UserHandlers) ShowObjectStoreUsers(c *gin.Context) { usersData := h.getObjectStoreUsersData(c) // Render HTML template + // Add cache-control headers to prevent browser caching of inline JavaScript c.Header("Content-Type", "text/html") + c.Header("Cache-Control", "no-cache, no-store, must-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + c.Header("ETag", fmt.Sprintf("\"%d\"", time.Now().Unix())) usersComponent := app.ObjectStoreUsers(usersData) layoutComponent := layout.Layout(c, usersComponent) err := layoutComponent.Render(c.Request.Context(), c.Writer) diff --git a/weed/admin/static/js/admin.js b/weed/admin/static/js/admin.js index 1758cde82..805445024 100644 --- a/weed/admin/static/js/admin.js +++ b/weed/admin/static/js/admin.js @@ -4,12 +4,12 @@ let bucketToDelete = ''; // Initialize dashboard when DOM is loaded -document.addEventListener('DOMContentLoaded', function() { +document.addEventListener('DOMContentLoaded', function () { initializeDashboard(); initializeEventHandlers(); setupFormValidation(); setupFileManagerEventHandlers(); - + // Initialize delete button visibility on file browser page if (window.location.pathname === '/files') { updateDeleteSelectedButton(); @@ -19,16 +19,16 @@ document.addEventListener('DOMContentLoaded', function() { function initializeDashboard() { // Set up HTMX event listeners setupHTMXListeners(); - + // Initialize tooltips initializeTooltips(); - + // Set up periodic refresh setupAutoRefresh(); - + // Set active navigation setActiveNavigation(); - + // Set up submenu behavior setupSubmenuBehavior(); } @@ -36,17 +36,17 @@ function initializeDashboard() { // HTMX event listeners function setupHTMXListeners() { // Show loading indicator on requests - document.body.addEventListener('htmx:beforeRequest', function(evt) { + document.body.addEventListener('htmx:beforeRequest', function (evt) { showLoadingIndicator(); }); - + // Hide loading indicator on completion - document.body.addEventListener('htmx:afterRequest', function(evt) { + document.body.addEventListener('htmx:afterRequest', function (evt) { hideLoadingIndicator(); }); - + // Handle errors - document.body.addEventListener('htmx:responseError', function(evt) { + document.body.addEventListener('htmx:responseError', function (evt) { handleHTMXError(evt); }); } @@ -62,7 +62,7 @@ function initializeTooltips() { // Set up auto-refresh for dashboard data function setupAutoRefresh() { // Refresh dashboard data every 30 seconds - setInterval(function() { + setInterval(function () { if (window.location.pathname === '/dashboard') { htmx.trigger('#dashboard-content', 'refresh'); } @@ -73,11 +73,11 @@ function setupAutoRefresh() { function setActiveNavigation() { const currentPath = window.location.pathname; const navLinks = document.querySelectorAll('.sidebar .nav-link'); - - navLinks.forEach(function(link) { + + navLinks.forEach(function (link) { const href = link.getAttribute('href'); let isActive = false; - + if (href === currentPath) { isActive = true; } else if (currentPath === '/' && href === '/admin') { @@ -86,7 +86,7 @@ function setActiveNavigation() { isActive = true; } // Note: Removed the problematic cluster condition that was highlighting all submenu items - + if (isActive) { link.classList.add('active'); } else { @@ -98,13 +98,13 @@ function setActiveNavigation() { // Set up submenu behavior function setupSubmenuBehavior() { const currentPath = window.location.pathname; - + // If we're on a cluster page, expand the cluster submenu if (currentPath.startsWith('/cluster/')) { const clusterSubmenu = document.getElementById('clusterSubmenu'); if (clusterSubmenu) { clusterSubmenu.classList.add('show'); - + // Update the parent toggle button state const toggleButton = document.querySelector('[data-bs-target="#clusterSubmenu"]'); if (toggleButton) { @@ -113,13 +113,13 @@ function setupSubmenuBehavior() { } } } - + // If we're on an object store page, expand the object store submenu if (currentPath.startsWith('/object-store/')) { const objectStoreSubmenu = document.getElementById('objectStoreSubmenu'); if (objectStoreSubmenu) { objectStoreSubmenu.classList.add('show'); - + // Update the parent toggle button state const toggleButton = document.querySelector('[data-bs-target="#objectStoreSubmenu"]'); if (toggleButton) { @@ -128,13 +128,13 @@ function setupSubmenuBehavior() { } } } - + // If we're on a maintenance page, expand the maintenance submenu if (currentPath.startsWith('/maintenance')) { const maintenanceSubmenu = document.getElementById('maintenanceSubmenu'); if (maintenanceSubmenu) { maintenanceSubmenu.classList.add('show'); - + // Update the parent toggle button state const toggleButton = document.querySelector('[data-bs-target="#maintenanceSubmenu"]'); if (toggleButton) { @@ -143,41 +143,41 @@ function setupSubmenuBehavior() { } } } - + // Prevent submenu from collapsing when clicking on submenu items const clusterSubmenuLinks = document.querySelectorAll('#clusterSubmenu .nav-link'); - clusterSubmenuLinks.forEach(function(link) { - link.addEventListener('click', function(e) { + clusterSubmenuLinks.forEach(function (link) { + link.addEventListener('click', function (e) { // Don't prevent the navigation, just stop the collapse behavior e.stopPropagation(); }); }); - + const objectStoreSubmenuLinks = document.querySelectorAll('#objectStoreSubmenu .nav-link'); - objectStoreSubmenuLinks.forEach(function(link) { - link.addEventListener('click', function(e) { + objectStoreSubmenuLinks.forEach(function (link) { + link.addEventListener('click', function (e) { // Don't prevent the navigation, just stop the collapse behavior e.stopPropagation(); }); }); - + const maintenanceSubmenuLinks = document.querySelectorAll('#maintenanceSubmenu .nav-link'); - maintenanceSubmenuLinks.forEach(function(link) { - link.addEventListener('click', function(e) { + maintenanceSubmenuLinks.forEach(function (link) { + link.addEventListener('click', function (e) { // Don't prevent the navigation, just stop the collapse behavior e.stopPropagation(); }); }); - + // Handle the main cluster toggle const clusterToggle = document.querySelector('[data-bs-target="#clusterSubmenu"]'); if (clusterToggle) { - clusterToggle.addEventListener('click', function(e) { + clusterToggle.addEventListener('click', function (e) { e.preventDefault(); - + const submenu = document.getElementById('clusterSubmenu'); const isExpanded = submenu.classList.contains('show'); - + if (isExpanded) { // Collapse submenu.classList.remove('show'); @@ -191,16 +191,16 @@ function setupSubmenuBehavior() { } }); } - + // Handle the main object store toggle const objectStoreToggle = document.querySelector('[data-bs-target="#objectStoreSubmenu"]'); if (objectStoreToggle) { - objectStoreToggle.addEventListener('click', function(e) { + objectStoreToggle.addEventListener('click', function (e) { e.preventDefault(); - + const submenu = document.getElementById('objectStoreSubmenu'); const isExpanded = submenu.classList.contains('show'); - + if (isExpanded) { // Collapse submenu.classList.remove('show'); @@ -214,16 +214,16 @@ function setupSubmenuBehavior() { } }); } - + // Handle the main maintenance toggle const maintenanceToggle = document.querySelector('[data-bs-target="#maintenanceSubmenu"]'); if (maintenanceToggle) { - maintenanceToggle.addEventListener('click', function(e) { + maintenanceToggle.addEventListener('click', function (e) { e.preventDefault(); - + const submenu = document.getElementById('maintenanceSubmenu'); const isExpanded = submenu.classList.contains('show'); - + if (isExpanded) { // Collapse submenu.classList.remove('show'); @@ -245,7 +245,7 @@ function showLoadingIndicator() { if (indicator) { indicator.style.display = 'block'; } - + // Add loading class to body document.body.classList.add('loading'); } @@ -255,7 +255,7 @@ function hideLoadingIndicator() { if (indicator) { indicator.style.display = 'none'; } - + // Remove loading class from body document.body.classList.remove('loading'); } @@ -263,10 +263,10 @@ function hideLoadingIndicator() { // Handle HTMX errors function handleHTMXError(evt) { console.error('HTMX Request Error:', evt.detail); - + // Show error toast or message showErrorMessage('Request failed. Please try again.'); - + hideLoadingIndicator(); } @@ -278,7 +278,7 @@ function showErrorMessage(message) { toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'assertive'); toast.setAttribute('aria-atomic', 'true'); - + toast.innerHTML = `
@@ -288,7 +288,7 @@ function showErrorMessage(message) {
`; - + // Add to toast container or create one let toastContainer = document.getElementById('toast-container'); if (!toastContainer) { @@ -298,15 +298,15 @@ function showErrorMessage(message) { toastContainer.style.zIndex = '1055'; document.body.appendChild(toastContainer); } - + toastContainer.appendChild(toast); - + // Show toast const bsToast = new bootstrap.Toast(toast); bsToast.show(); - + // Remove toast element after it's hidden - toast.addEventListener('hidden.bs.toast', function() { + toast.addEventListener('hidden.bs.toast', function () { toast.remove(); }); } @@ -318,7 +318,7 @@ function showSuccessMessage(message) { toast.setAttribute('role', 'alert'); toast.setAttribute('aria-live', 'assertive'); toast.setAttribute('aria-atomic', 'true'); - + toast.innerHTML = `
@@ -328,7 +328,7 @@ function showSuccessMessage(message) {
`; - + let toastContainer = document.getElementById('toast-container'); if (!toastContainer) { toastContainer = document.createElement('div'); @@ -337,13 +337,13 @@ function showSuccessMessage(message) { toastContainer.style.zIndex = '1055'; document.body.appendChild(toastContainer); } - + toastContainer.appendChild(toast); - + const bsToast = new bootstrap.Toast(toast); bsToast.show(); - - toast.addEventListener('hidden.bs.toast', function() { + + toast.addEventListener('hidden.bs.toast', function () { toast.remove(); }); } @@ -351,13 +351,13 @@ function showSuccessMessage(message) { // Format bytes for display function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; - + const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; - + const i = Math.floor(Math.log(bytes) / Math.log(k)); - + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } @@ -380,7 +380,7 @@ function confirmAction(message, callback) { } // Global error handler -window.addEventListener('error', function(e) { +window.addEventListener('error', function (e) { console.error('Global error:', e.error); showErrorMessage('An unexpected error occurred.'); }); @@ -392,7 +392,7 @@ window.Dashboard = { formatBytes, formatNumber, confirmAction -}; +}; // Initialize event handlers function initializeEventHandlers() { @@ -403,13 +403,13 @@ function initializeEventHandlers() { } // Delete bucket buttons - document.addEventListener('click', function(e) { + document.addEventListener('click', function (e) { if (e.target.closest('.delete-bucket-btn')) { const button = e.target.closest('.delete-bucket-btn'); const bucketName = button.getAttribute('data-bucket-name'); confirmDeleteBucket(bucketName); } - + // Quota management buttons if (e.target.closest('.quota-btn')) { const button = e.target.closest('.quota-btn'); @@ -429,7 +429,7 @@ function initializeEventHandlers() { // Enable quota checkbox for create bucket form const enableQuotaCheckbox = document.getElementById('enableQuota'); if (enableQuotaCheckbox) { - enableQuotaCheckbox.addEventListener('change', function() { + enableQuotaCheckbox.addEventListener('change', function () { const quotaSettings = document.getElementById('quotaSettings'); if (this.checked) { quotaSettings.style.display = 'block'; @@ -442,7 +442,7 @@ function initializeEventHandlers() { // Enable quota checkbox for quota modal const quotaEnabledCheckbox = document.getElementById('quotaEnabled'); if (quotaEnabledCheckbox) { - quotaEnabledCheckbox.addEventListener('change', function() { + quotaEnabledCheckbox.addEventListener('change', function () { const quotaSizeSettings = document.getElementById('quotaSizeSettings'); if (this.checked) { quotaSizeSettings.style.display = 'block'; @@ -467,7 +467,7 @@ function setupFormValidation() { // Handle create bucket form submission async function handleCreateBucket(event) { event.preventDefault(); - + const form = event.target; const formData = new FormData(form); const bucketData = { @@ -492,14 +492,14 @@ async function handleCreateBucket(event) { if (response.ok) { // Success showAlert('success', `Bucket "${bucketData.name}" created successfully!`); - + // Close modal const modal = bootstrap.Modal.getInstance(document.getElementById('createBucketModal')); modal.hide(); - + // Reset form form.reset(); - + // Refresh the page after a short delay setTimeout(() => { location.reload(); @@ -519,7 +519,7 @@ function validateBucketName(event) { const input = event.target; const value = input.value; const isValid = /^[a-z0-9.-]+$/.test(value) && value.length >= 3 && value.length <= 63; - + if (value.length > 0 && !isValid) { input.setCustomValidity('Bucket name must contain only lowercase letters, numbers, dots, and hyphens (3-63 characters)'); } else { @@ -531,7 +531,7 @@ function validateBucketName(event) { function confirmDeleteBucket(bucketName) { bucketToDelete = bucketName; document.getElementById('deleteBucketName').textContent = bucketName; - + const modal = new bootstrap.Modal(document.getElementById('deleteBucketModal')); modal.show(); } @@ -552,11 +552,11 @@ async function deleteBucket() { if (response.ok) { // Success showAlert('success', `Bucket "${bucketToDelete}" deleted successfully!`); - + // Close modal const modal = bootstrap.Modal.getInstance(document.getElementById('deleteBucketModal')); modal.hide(); - + // Refresh the page after a short delay setTimeout(() => { location.reload(); @@ -588,7 +588,7 @@ function exportBucketList() { const data = rows.map(row => { const cells = row.querySelectorAll('td'); if (cells.length < 5) return null; // Skip empty state row - + return { name: cells[0].textContent.trim(), created: cells[1].textContent.trim(), @@ -639,7 +639,7 @@ function showAlert(type, message) { min-width: 300px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); `; - + alert.innerHTML = ` ${message} @@ -684,9 +684,9 @@ function exportVolumeServers() { showErrorMessage('No volume servers data to export'); return; } - + let csv = 'Server ID,Address,Data Center,Rack,Volumes,Capacity,Usage\n'; - + const rows = table.querySelectorAll('tbody tr'); rows.forEach(row => { const cells = row.querySelectorAll('td'); @@ -703,7 +703,7 @@ function exportVolumeServers() { csv += rowData.join(',') + '\n'; } }); - + downloadCSV(csv, 'seaweedfs-volume-servers.csv'); } @@ -714,7 +714,7 @@ function exportVolumes() { showErrorMessage('No volumes data to export'); return; } - + // Get headers from the table (dynamically handles conditional columns) const headerCells = table.querySelectorAll('thead th'); const headers = []; @@ -724,9 +724,9 @@ function exportVolumes() { headers.push(cell.textContent.trim()); } }); - + let csv = headers.join(',') + '\n'; - + const rows = table.querySelectorAll('tbody tr'); rows.forEach(row => { const cells = row.querySelectorAll('td'); @@ -735,9 +735,9 @@ function exportVolumes() { for (let i = 0; i < cells.length - 1; i++) { rowData.push(`"${cells[i].textContent.trim().replace(/"/g, '""')}"`); } - csv += rowData.join(',') + '\n'; + csv += rowData.join(',') + '\n'; }); - + downloadCSV(csv, 'seaweedfs-volumes.csv'); } @@ -854,15 +854,15 @@ function exportUsers() { showAlert('error', 'Users table not found'); return; } - + const rows = table.querySelectorAll('tbody tr'); if (rows.length === 0) { showErrorMessage('No users to export'); return; } - + let csvContent = 'Username,Email,Access Key,Status,Created,Last Login\n'; - + rows.forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length >= 6) { @@ -872,11 +872,11 @@ function exportUsers() { const status = cells[3].textContent.trim(); const created = cells[4].textContent.trim(); const lastLogin = cells[5].textContent.trim(); - + csvContent += `"${username}","${email}","${accessKey}","${status}","${created}","${lastLogin}"\n`; } }); - + downloadCSV(csvContent, 'seaweedfs-users.csv'); } @@ -884,12 +884,12 @@ function exportUsers() { function confirmDeleteCollection(button) { const collectionName = button.getAttribute('data-collection-name'); document.getElementById('deleteCollectionName').textContent = collectionName; - + const modal = new bootstrap.Modal(document.getElementById('deleteCollectionModal')); modal.show(); - + // Set up confirm button - document.getElementById('confirmDeleteCollection').onclick = function() { + document.getElementById('confirmDeleteCollection').onclick = function () { deleteCollection(collectionName); }; } @@ -903,7 +903,7 @@ async function deleteCollection(collectionName) { 'Content-Type': 'application/json', } }); - + if (response.ok) { showSuccessMessage(`Collection "${collectionName}" deleted successfully`); // Hide modal @@ -929,7 +929,7 @@ async function deleteCollection(collectionName) { function downloadCSV(csvContent, filename) { const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); - + if (link.download !== undefined) { const url = URL.createObjectURL(blob); link.setAttribute('href', url); @@ -947,11 +947,11 @@ function downloadCSV(csvContent, filename) { function toggleSelectAll() { const selectAll = document.getElementById('selectAll'); const checkboxes = document.querySelectorAll('.file-checkbox'); - + checkboxes.forEach(checkbox => { checkbox.checked = selectAll.checked; }); - + updateDeleteSelectedButton(); } @@ -959,7 +959,7 @@ function toggleSelectAll() { function updateDeleteSelectedButton() { const checkboxes = document.querySelectorAll('.file-checkbox:checked'); const deleteBtn = document.getElementById('deleteSelectedBtn'); - + if (deleteBtn) { if (checkboxes.length > 0) { deleteBtn.style.display = 'inline-block'; @@ -975,7 +975,7 @@ function updateSelectAllCheckbox() { const selectAll = document.getElementById('selectAll'); const allCheckboxes = document.querySelectorAll('.file-checkbox'); const checkedCheckboxes = document.querySelectorAll('.file-checkbox:checked'); - + if (selectAll && allCheckboxes.length > 0) { if (checkedCheckboxes.length === 0) { selectAll.checked = false; @@ -999,17 +999,17 @@ function getSelectedFilePaths() { // Confirm delete selected files function confirmDeleteSelected() { const selectedPaths = getSelectedFilePaths(); - + if (selectedPaths.length === 0) { showAlert('warning', 'No files selected'); return; } - + const fileNames = selectedPaths.map(path => path.split('/').pop()).join(', '); - const message = selectedPaths.length === 1 + const message = selectedPaths.length === 1 ? `Are you sure you want to delete "${fileNames}"?` : `Are you sure you want to delete ${selectedPaths.length} selected items?\n\n${fileNames.substring(0, 200)}${fileNames.length > 200 ? '...' : ''}`; - + if (confirm(message)) { deleteSelectedFiles(selectedPaths); } @@ -1021,13 +1021,13 @@ async function deleteSelectedFiles(filePaths) { showAlert('warning', 'No files selected'); return; } - + // Disable the delete button during operation const deleteBtn = document.getElementById('deleteSelectedBtn'); const originalText = deleteBtn.innerHTML; deleteBtn.disabled = true; deleteBtn.innerHTML = 'Deleting...'; - + try { const response = await fetch('/api/files/delete-multiple', { method: 'DELETE', @@ -1039,7 +1039,7 @@ async function deleteSelectedFiles(filePaths) { if (response.ok) { const result = await response.json(); - + if (result.deleted > 0) { if (result.failed === 0) { showAlert('success', `Successfully deleted ${result.deleted} item(s)`); @@ -1049,7 +1049,7 @@ async function deleteSelectedFiles(filePaths) { console.warn('Deletion errors:', result.errors); } } - + // Reload the page to update the file list setTimeout(() => { window.location.reload(); @@ -1091,31 +1091,31 @@ function uploadFile() { async function submitCreateFolder() { const folderName = document.getElementById('folderName').value.trim(); const currentPath = document.getElementById('currentPath').value; - + if (!folderName) { showErrorMessage('Please enter a folder name'); return; } - + // Validate folder name if (folderName.includes('/') || folderName.includes('\\')) { showErrorMessage('Folder names cannot contain / or \\ characters'); return; } - + // Additional validation for reserved names const reservedNames = ['.', '..', 'CON', 'PRN', 'AUX', 'NUL']; if (reservedNames.includes(folderName.toUpperCase())) { showErrorMessage('This folder name is reserved and cannot be used'); return; } - + // Disable the button to prevent double submission const submitButton = document.querySelector('#createFolderModal .btn-primary'); const originalText = submitButton.innerHTML; submitButton.disabled = true; submitButton.innerHTML = 'Creating...'; - + try { const response = await fetch('/api/files/create-folder', { method: 'POST', @@ -1157,37 +1157,37 @@ async function submitCreateFolder() { async function submitUploadFile() { const fileInput = document.getElementById('fileInput'); const currentPath = document.getElementById('uploadPath').value; - + if (!fileInput.files || fileInput.files.length === 0) { showErrorMessage('Please select at least one file to upload'); return; } - + const files = Array.from(fileInput.files); const totalSize = files.reduce((sum, file) => sum + file.size, 0); - + // Validate total file size (limit to 500MB for admin interface) const maxSize = 500 * 1024 * 1024; // 500MB total if (totalSize > maxSize) { showErrorMessage('Total file size exceeds 500MB limit. Please select fewer or smaller files.'); return; } - + // Individual file size validation removed - no limit per file - + const formData = new FormData(); files.forEach(file => { formData.append('files', file); }); formData.append('path', currentPath); - + // Show progress bar and disable button const progressContainer = document.getElementById('uploadProgress'); const progressBar = progressContainer.querySelector('.progress-bar'); const uploadStatus = document.getElementById('uploadStatus'); const submitButton = document.querySelector('#uploadFileModal .btn-primary'); const originalText = submitButton.innerHTML; - + progressContainer.style.display = 'block'; progressBar.style.width = '0%'; progressBar.setAttribute('aria-valuenow', '0'); @@ -1195,12 +1195,12 @@ async function submitUploadFile() { uploadStatus.textContent = `Uploading ${files.length} file(s)...`; submitButton.disabled = true; submitButton.innerHTML = 'Uploading...'; - + try { const xhr = new XMLHttpRequest(); - + // Handle progress - xhr.upload.addEventListener('progress', function(e) { + xhr.upload.addEventListener('progress', function (e) { if (e.lengthComputable) { const percentComplete = Math.round((e.loaded / e.total) * 100); progressBar.style.width = percentComplete + '%'; @@ -1209,13 +1209,13 @@ async function submitUploadFile() { uploadStatus.textContent = `Uploading ${files.length} file(s)... ${percentComplete}%`; } }); - + // Handle completion - xhr.addEventListener('load', function() { + xhr.addEventListener('load', function () { if (xhr.status === 200) { try { const response = JSON.parse(xhr.responseText); - + if (response.uploaded > 0) { if (response.failed === 0) { showSuccessMessage(`Successfully uploaded ${response.uploaded} file(s)`); @@ -1226,7 +1226,7 @@ async function submitUploadFile() { console.warn('Upload errors:', response.errors); } } - + // Hide modal and refresh page const modal = bootstrap.Modal.getInstance(document.getElementById('uploadFileModal')); modal.hide(); @@ -1256,23 +1256,23 @@ async function submitUploadFile() { progressContainer.style.display = 'none'; } }); - + // Handle errors - xhr.addEventListener('error', function() { + xhr.addEventListener('error', function () { showErrorMessage('Failed to upload files. Please check your connection and try again.'); progressContainer.style.display = 'none'; }); - + // Handle abort - xhr.addEventListener('abort', function() { + xhr.addEventListener('abort', function () { showErrorMessage('File upload was cancelled.'); progressContainer.style.display = 'none'; }); - + // Send request xhr.open('POST', '/api/files/upload'); xhr.send(formData); - + } catch (error) { console.error('Upload error:', error); showErrorMessage('Failed to upload files. Please try again.'); @@ -1331,16 +1331,16 @@ function downloadFile(filePath) { async function viewFile(filePath) { try { const response = await fetch(`/api/files/view?path=${encodeURIComponent(filePath)}`); - + if (!response.ok) { const error = await response.json(); showAlert('error', `Failed to view file: ${error.error || 'Unknown error'}`); return; } - + const data = await response.json(); showFileViewer(data); - + } catch (error) { console.error('View file error:', error); showAlert('error', 'Failed to view file'); @@ -1351,16 +1351,16 @@ async function viewFile(filePath) { async function showProperties(filePath) { try { const response = await fetch(`/api/files/properties?path=${encodeURIComponent(filePath)}`); - + if (!response.ok) { const error = await response.json(); showAlert('error', `Failed to get file properties: ${error.error || 'Unknown error'}`); return; } - + const properties = await response.json(); showPropertiesModal(properties); - + } catch (error) { console.error('Properties error:', error); showAlert('error', 'Failed to get file properties'); @@ -1404,45 +1404,45 @@ function setupFileManagerEventHandlers() { // Handle Enter key in folder name input const folderNameInput = document.getElementById('folderName'); if (folderNameInput) { - folderNameInput.addEventListener('keypress', function(e) { + folderNameInput.addEventListener('keypress', function (e) { if (e.key === 'Enter') { e.preventDefault(); submitCreateFolder(); } }); } - + // Handle file selection change to show preview const fileInput = document.getElementById('fileInput'); if (fileInput) { - fileInput.addEventListener('change', function(e) { + fileInput.addEventListener('change', function (e) { updateFileListPreview(); }); } - + // Setup checkbox event listeners for file selection const checkboxes = document.querySelectorAll('.file-checkbox'); checkboxes.forEach(checkbox => { - checkbox.addEventListener('change', function() { + checkbox.addEventListener('change', function () { updateDeleteSelectedButton(); updateSelectAllCheckbox(); }); }); - + // Setup drag and drop for file uploads setupDragAndDrop(); - + // Clear form when modals are hidden const createFolderModal = document.getElementById('createFolderModal'); if (createFolderModal) { - createFolderModal.addEventListener('hidden.bs.modal', function() { + createFolderModal.addEventListener('hidden.bs.modal', function () { document.getElementById('folderName').value = ''; }); } - + const uploadFileModal = document.getElementById('uploadFileModal'); if (uploadFileModal) { - uploadFileModal.addEventListener('hidden.bs.modal', function() { + uploadFileModal.addEventListener('hidden.bs.modal', function () { const fileInput = document.getElementById('fileInput'); const progressContainer = document.getElementById('uploadProgress'); const fileListPreview = document.getElementById('fileListPreview'); @@ -1457,32 +1457,32 @@ function setupFileManagerEventHandlers() { function setupDragAndDrop() { const dropZone = document.querySelector('.card-body'); // Main file listing area const uploadModal = document.getElementById('uploadFileModal'); - + if (!dropZone || !uploadModal) return; - + // Prevent default drag behaviors ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, preventDefaults, false); document.body.addEventListener(eventName, preventDefaults, false); }); - + // Highlight drop zone when item is dragged over it ['dragenter', 'dragover'].forEach(eventName => { dropZone.addEventListener(eventName, highlight, false); }); - + ['dragleave', 'drop'].forEach(eventName => { dropZone.addEventListener(eventName, unhighlight, false); }); - + // Handle dropped files dropZone.addEventListener('drop', handleDrop, false); - + function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); } - + function highlight(e) { dropZone.classList.add('drag-over'); // Add some visual feedback @@ -1514,7 +1514,7 @@ function setupDragAndDrop() { dropZone.appendChild(overlay); } } - + function unhighlight(e) { dropZone.classList.remove('drag-over'); const overlay = dropZone.querySelector('.drag-overlay'); @@ -1522,23 +1522,23 @@ function setupDragAndDrop() { overlay.remove(); } } - + function handleDrop(e) { const dt = e.dataTransfer; const files = dt.files; - + if (files.length > 0) { // Open upload modal and set files const fileInput = document.getElementById('fileInput'); if (fileInput) { // Create a new FileList-like object const fileArray = Array.from(files); - + // Set files to input (this is a bit tricky with file inputs) const dataTransfer = new DataTransfer(); fileArray.forEach(file => dataTransfer.items.add(file)); fileInput.files = dataTransfer.files; - + // Update preview and show modal updateFileListPreview(); const modal = new bootstrap.Modal(uploadModal); @@ -1553,20 +1553,20 @@ function updateFileListPreview() { const fileInput = document.getElementById('fileInput'); const fileListPreview = document.getElementById('fileListPreview'); const selectedFilesList = document.getElementById('selectedFilesList'); - + if (!fileInput.files || fileInput.files.length === 0) { fileListPreview.style.display = 'none'; return; } - + const files = Array.from(fileInput.files); const totalSize = files.reduce((sum, file) => sum + file.size, 0); - + let html = `
${files.length} file(s) selected Total: ${formatBytes(totalSize)}
`; - + files.forEach((file, index) => { const fileIcon = getFileIconByName(file.name); html += `
@@ -1577,7 +1577,7 @@ function updateFileListPreview() { ${formatBytes(file.size)}
`; }); - + selectedFilesList.innerHTML = html; fileListPreview.style.display = 'block'; } @@ -1585,7 +1585,7 @@ function updateFileListPreview() { // Get file icon based on file name/extension function getFileIconByName(fileName) { const ext = fileName.split('.').pop().toLowerCase(); - + switch (ext) { case 'jpg': case 'jpeg': @@ -1643,14 +1643,14 @@ function getFileIconByName(fileName) { function showQuotaModal(bucketName, currentQuotaMB, quotaEnabled) { document.getElementById('quotaBucketName').value = bucketName; document.getElementById('quotaEnabled').checked = quotaEnabled; - + // Convert quota to appropriate unit and set values const quotaBytes = currentQuotaMB * 1024 * 1024; // Convert MB to bytes const { size, unit } = convertBytesToBestUnit(quotaBytes); - + document.getElementById('quotaSizeMB').value = size; document.getElementById('quotaUnitMB').value = unit; - + // Show/hide quota size settings based on enabled state const quotaSizeSettings = document.getElementById('quotaSizeSettings'); if (quotaEnabled) { @@ -1658,7 +1658,7 @@ function showQuotaModal(bucketName, currentQuotaMB, quotaEnabled) { } else { quotaSizeSettings.style.display = 'none'; } - + const modal = new bootstrap.Modal(document.getElementById('manageQuotaModal')); modal.show(); } @@ -1668,17 +1668,17 @@ function convertBytesToBestUnit(bytes) { if (bytes === 0) { return { size: 0, unit: 'MB' }; } - + // Check if it's a clean TB value if (bytes >= 1024 * 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024 * 1024) === 0) { return { size: bytes / (1024 * 1024 * 1024 * 1024), unit: 'TB' }; } - + // Check if it's a clean GB value if (bytes >= 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024) === 0) { return { size: bytes / (1024 * 1024 * 1024), unit: 'GB' }; } - + // Default to MB return { size: bytes / (1024 * 1024), unit: 'MB' }; } @@ -1686,11 +1686,11 @@ function convertBytesToBestUnit(bytes) { // Handle quota update form submission async function handleUpdateQuota(event) { event.preventDefault(); - + const form = event.target; const formData = new FormData(form); const bucketName = document.getElementById('quotaBucketName').value; - + const quotaData = { quota_enabled: formData.get('quota_enabled') === 'on', quota_size: parseInt(formData.get('quota_size')) || 0, @@ -1711,11 +1711,11 @@ async function handleUpdateQuota(event) { if (response.ok) { // Success showAlert('success', `Quota for bucket "${bucketName}" updated successfully!`); - + // Close modal const modal = bootstrap.Modal.getInstance(document.getElementById('manageQuotaModal')); modal.hide(); - + // Refresh the page after a short delay setTimeout(() => { location.reload(); @@ -1735,7 +1735,7 @@ function showFileViewer(data) { const file = data.file; const content = data.content || ''; const viewable = data.viewable !== false; - + // Create modal HTML const modalHtml = `
`; - + // Remove existing modal if any const existingModal = document.getElementById('fileViewerModal'); if (existingModal) { existingModal.remove(); } - + // Add modal to DOM document.body.insertAdjacentHTML('beforeend', modalHtml); - + // Show modal const modal = new bootstrap.Modal(document.getElementById('fileViewerModal')); modal.show(); - + // Clean up when modal is hidden document.getElementById('fileViewerModal').addEventListener('hidden.bs.modal', function () { this.remove(); @@ -1853,7 +1853,7 @@ function getLanguageFromMime(mime, filename) { case 'text/sql': return 'sql'; case 'text/markdown': return 'markdown'; } - + // Fallback to file extension const ext = filename.split('.').pop().toLowerCase(); switch (ext) { @@ -1908,20 +1908,20 @@ function showPropertiesModal(properties) {
`; - + // Remove existing modal if any const existingModal = document.getElementById('propertiesModal'); if (existingModal) { existingModal.remove(); } - + // Add modal to DOM document.body.insertAdjacentHTML('beforeend', modalHtml); - + // Show modal const modal = new bootstrap.Modal(document.getElementById('propertiesModal')); modal.show(); - + // Clean up when modal is hidden document.getElementById('propertiesModal').addEventListener('hidden.bs.modal', function () { this.remove(); @@ -1939,14 +1939,14 @@ function createPropertiesContent(properties) { Full Path:${properties.full_path} Type:${properties.is_directory ? 'Directory' : 'File'} `; - + if (!properties.is_directory) { html += ` Size:${properties.size_formatted || formatBytes(properties.size || 0)} MIME Type:${properties.mime_type || 'Unknown'} `; } - + html += ` @@ -1954,14 +1954,14 @@ function createPropertiesContent(properties) {
Timestamps
`; - + if (properties.modified_time) { html += ``; } if (properties.created_time) { html += ``; } - + html += `
Modified:${properties.modified_time}
Created:${properties.created_time}
@@ -1974,7 +1974,7 @@ function createPropertiesContent(properties) { `; - + // Add TTL information if available if (properties.ttl_seconds && properties.ttl_seconds > 0) { html += ` @@ -1988,7 +1988,7 @@ function createPropertiesContent(properties) { `; } - + // Add chunk information if available if (properties.chunks && properties.chunks.length > 0) { html += ` @@ -2007,7 +2007,7 @@ function createPropertiesContent(properties) { `; - + properties.chunks.forEach(chunk => { html += ` @@ -2018,7 +2018,7 @@ function createPropertiesContent(properties) { `; }); - + html += ` @@ -2027,7 +2027,7 @@ function createPropertiesContent(properties) { `; } - + // Add extended attributes if available if (properties.extended && Object.keys(properties.extended).length > 0) { html += ` @@ -2036,18 +2036,18 @@ function createPropertiesContent(properties) {
Extended Attributes
`; - + Object.entries(properties.extended).forEach(([key, value]) => { html += ``; }); - + html += `
${key}:${value}
`; } - + return html; } @@ -2060,7 +2060,7 @@ function escapeHtml(text) { '"': '"', "'": ''' }; - return text.replace(/[&<>"']/g, function(m) { return map[m]; }); + return text.replace(/[&<>"']/g, function (m) { return map[m]; }); } // ============================================================================ @@ -2076,18 +2076,18 @@ let currentAccessKeysUser = ''; async function handleCreateUser() { const form = document.getElementById('createUserForm'); const formData = new FormData(form); - + // Get selected actions const actionsSelect = document.getElementById('actions'); const selectedActions = Array.from(actionsSelect.selectedOptions).map(option => option.value); - + const userData = { username: formData.get('username'), email: formData.get('email'), actions: selectedActions, generate_key: formData.get('generateKey') === 'on' }; - + try { const response = await fetch('/api/users', { method: 'POST', @@ -2096,16 +2096,16 @@ async function handleCreateUser() { }, body: JSON.stringify(userData) }); - + if (response.ok) { const result = await response.json(); showSuccessMessage('User created successfully'); - + // Show the created access key if generated if (result.user && result.user.access_key) { showNewAccessKeyModal(result.user); } - + // Close modal and refresh page const modal = bootstrap.Modal.getInstance(document.getElementById('createUserModal')); modal.hide(); @@ -2123,22 +2123,30 @@ async function handleCreateUser() { async function editUser(username) { currentEditingUser = username; - + try { const response = await fetch(`/api/users/${username}`); if (response.ok) { const user = await response.json(); - + // Populate edit form document.getElementById('editUsername').value = username; - document.getElementById('editEmail').value = user.email || ''; - + document.getElementById('editEmail').value = user.email || ''; + // Set selected actions const actionsSelect = document.getElementById('editActions'); Array.from(actionsSelect.options).forEach(option => { option.selected = user.actions && user.actions.includes(option.value); }); - + + // Set selected policies + const policiesSelect = document.getElementById('editPolicies'); + if (policiesSelect) { + Array.from(policiesSelect.options).forEach(option => { + option.selected = user.policy_names && user.policy_names.includes(option.value); + }); + } + // Show modal const modal = new bootstrap.Modal(document.getElementById('editUserModal')); modal.show(); @@ -2154,16 +2162,21 @@ async function editUser(username) { async function handleUpdateUser() { const form = document.getElementById('editUserForm'); const formData = new FormData(form); - + // Get selected actions const actionsSelect = document.getElementById('editActions'); const selectedActions = Array.from(actionsSelect.selectedOptions).map(option => option.value); - + + // Get selected policies + const policiesSelect = document.getElementById('editPolicies'); + const selectedPolicies = policiesSelect ? Array.from(policiesSelect.selectedOptions).map(option => option.value) : []; + const userData = { email: formData.get('email'), - actions: selectedActions + actions: selectedActions, + policy_names: selectedPolicies }; - + try { const response = await fetch(`/api/users/${currentEditingUser}`, { method: 'PUT', @@ -2172,10 +2185,10 @@ async function handleUpdateUser() { }, body: JSON.stringify(userData) }); - + if (response.ok) { showSuccessMessage('User updated successfully'); - + // Close modal and refresh page const modal = bootstrap.Modal.getInstance(document.getElementById('editUserModal')); modal.hide(); @@ -2206,7 +2219,7 @@ async function deleteUserConfirmed(username) { const response = await fetch(`/api/users/${username}`, { method: 'DELETE' }); - + if (response.ok) { showSuccessMessage('User deleted successfully'); setTimeout(() => window.location.reload(), 1000); @@ -2225,10 +2238,10 @@ async function showUserDetails(username) { const response = await fetch(`/api/users/${username}`); if (response.ok) { const user = await response.json(); - + const content = createUserDetailsContent(user); document.getElementById('userDetailsContent').innerHTML = content; - + const modal = new bootstrap.Modal(document.getElementById('userDetailsModal')); modal.show(); } else { @@ -2259,17 +2272,17 @@ function createUserDetailsContent(user) {
Permissions
- ${user.actions && user.actions.length > 0 ? - user.actions.map(action => `${action}`).join('') : - 'No permissions assigned' - } + ${user.actions && user.actions.length > 0 ? + user.actions.map(action => `${action}`).join('') : + 'No permissions assigned' + }
Access Keys
- ${user.access_keys && user.access_keys.length > 0 ? - createAccessKeysTable(user.access_keys) : - '

No access keys

' - } + ${user.access_keys && user.access_keys.length > 0 ? + createAccessKeysTable(user.access_keys) : + '

No access keys

' + }
`; @@ -2301,9 +2314,9 @@ function createAccessKeysTable(accessKeys) { async function manageAccessKeys(username) { currentAccessKeysUser = username; document.getElementById('accessKeysUsername').textContent = username; - + await loadAccessKeys(username); - + const modal = new bootstrap.Modal(document.getElementById('accessKeysModal')); modal.show(); } @@ -2313,7 +2326,7 @@ async function loadAccessKeys(username) { const response = await fetch(`/api/users/${username}`); if (response.ok) { const user = await response.json(); - + const content = createAccessKeysManagementContent(user.access_keys || []); document.getElementById('accessKeysContent').innerHTML = content; } else { @@ -2329,7 +2342,7 @@ function createAccessKeysManagementContent(accessKeys) { if (accessKeys.length === 0) { return '

No access keys found. Create one to get started.

'; } - + return `
@@ -2375,7 +2388,7 @@ async function createAccessKey() { showErrorMessage('No user selected'); return; } - + try { const response = await fetch(`/api/users/${currentAccessKeysUser}/access-keys`, { method: 'POST', @@ -2383,14 +2396,14 @@ async function createAccessKey() { 'Content-Type': 'application/json', } }); - + if (response.ok) { const result = await response.json(); showSuccessMessage('Access key created successfully'); - + // Show the new access key showNewAccessKeyModal(result.access_key); - + // Reload access keys await loadAccessKeys(currentAccessKeysUser); } else { @@ -2415,10 +2428,10 @@ async function deleteAccessKeyConfirmed(accessKeyId) { const response = await fetch(`/api/users/${currentAccessKeysUser}/access-keys/${accessKeyId}`, { method: 'DELETE' }); - + if (response.ok) { showSuccessMessage('Access key deleted successfully'); - + // Reload access keys await loadAccessKeys(currentAccessKeysUser); } else { @@ -2456,7 +2469,7 @@ function showSecretKey(accessKey, secretKey) { `; - + showModal('Access Key Details', content); } @@ -2489,7 +2502,7 @@ function showNewAccessKeyModal(accessKeyData) { `; - + showModal('New Access Key Created', content); } @@ -2514,20 +2527,19 @@ function showModal(title, content) { `; - + // Add modal to body document.body.insertAdjacentHTML('beforeend', modalHtml); - + // Show modal const modal = new bootstrap.Modal(document.getElementById(modalId)); modal.show(); - + // Remove modal from DOM when hidden - document.getElementById(modalId).addEventListener('hidden.bs.modal', function() { + document.getElementById(modalId).addEventListener('hidden.bs.modal', function () { this.remove(); }); } - \ No newline at end of file diff --git a/weed/admin/view/app/object_store_users.templ b/weed/admin/view/app/object_store_users.templ index 686f57e1c..e93f39d75 100644 --- a/weed/admin/view/app/object_store_users.templ +++ b/weed/admin/view/app/object_store_users.templ @@ -223,6 +223,13 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) { Hold Ctrl/Cmd to select multiple permissions +
+ + + Hold Ctrl/Cmd to select multiple policies +
+
+ + +
Create New User
Hold Ctrl/Cmd to select multiple permissions
Hold Ctrl/Cmd to select multiple policies
Edit User
User Details
Manage Access Keys
Access Keys for
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/weed/iam/integration/iam_manager.go b/weed/iam/integration/iam_manager.go index 240c5fb96..caaa7f31d 100644 --- a/weed/iam/integration/iam_manager.go +++ b/weed/iam/integration/iam_manager.go @@ -77,6 +77,9 @@ type ActionRequest struct { // RequestContext contains additional request information RequestContext map[string]interface{} `json:"requestContext,omitempty"` + + // PolicyNames to evaluate (overrides role-based policies if present) + PolicyNames []string `json:"policyNames,omitempty"` } // NewIAMManager creates a new IAM manager @@ -281,14 +284,32 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest return false, fmt.Errorf("IAM manager not initialized") } - // Validate session token first (skip for OIDC tokens which are already validated) - if !isOIDCToken(request.SessionToken) { + // Validate session token if present (skip for OIDC tokens which are already validated, + // and skip for empty tokens which represent static access keys) + if request.SessionToken != "" && !isOIDCToken(request.SessionToken) { _, err := m.stsService.ValidateSessionToken(ctx, request.SessionToken) if err != nil { return false, fmt.Errorf("invalid session: %w", err) } } + // Create evaluation context + evalCtx := &policy.EvaluationContext{ + Principal: request.Principal, + Action: request.Action, + Resource: request.Resource, + RequestContext: request.RequestContext, + } + + // If explicit policy names are provided (e.g. from user identity), evaluate them directly + if len(request.PolicyNames) > 0 { + result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, request.PolicyNames) + if err != nil { + return false, fmt.Errorf("policy evaluation failed: %w", err) + } + return result.Effect == policy.EffectAllow, nil + } + // Extract role name from principal ARN roleName := utils.ExtractRoleNameFromPrincipal(request.Principal) if roleName == "" { @@ -301,14 +322,6 @@ func (m *IAMManager) IsActionAllowed(ctx context.Context, request *ActionRequest return false, fmt.Errorf("role not found: %s", roleName) } - // Create evaluation context - evalCtx := &policy.EvaluationContext{ - Principal: request.Principal, - Action: request.Action, - Resource: request.Resource, - RequestContext: request.RequestContext, - } - // Evaluate policies attached to the role result, err := m.policyEngine.Evaluate(ctx, "", evalCtx, roleDef.AttachedPolicies) if err != nil { diff --git a/weed/pb/filer_pb/filer.pb.go b/weed/pb/filer_pb/filer.pb.go index 31de4e652..7e2cdedf3 100644 --- a/weed/pb/filer_pb/filer.pb.go +++ b/weed/pb/filer_pb/filer.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: filer.proto package filer_pb diff --git a/weed/pb/filer_pb/filer_grpc.pb.go b/weed/pb/filer_pb/filer_grpc.pb.go index bc892380c..e74a4c712 100644 --- a/weed/pb/filer_pb/filer_grpc.pb.go +++ b/weed/pb/filer_pb/filer_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: filer.proto package filer_pb diff --git a/weed/pb/iam.proto b/weed/pb/iam.proto index 6720a0456..d485ce011 100644 --- a/weed/pb/iam.proto +++ b/weed/pb/iam.proto @@ -27,6 +27,7 @@ message Identity { Account account = 4; bool disabled = 5; // User status: false = enabled (default), true = disabled repeated string service_account_ids = 6; // IDs of service accounts owned by this user + repeated string policy_names = 7; } message Credential { diff --git a/weed/pb/iam_pb/iam.pb.go b/weed/pb/iam_pb/iam.pb.go index b40dc486a..4b0976410 100644 --- a/weed/pb/iam_pb/iam.pb.go +++ b/weed/pb/iam_pb/iam.pb.go @@ -89,6 +89,7 @@ type Identity struct { Account *Account `protobuf:"bytes,4,opt,name=account,proto3" json:"account,omitempty"` Disabled bool `protobuf:"varint,5,opt,name=disabled,proto3" json:"disabled,omitempty"` // User status: false = enabled (default), true = disabled ServiceAccountIds []string `protobuf:"bytes,6,rep,name=service_account_ids,json=serviceAccountIds,proto3" json:"service_account_ids,omitempty"` // IDs of service accounts owned by this user + PolicyNames []string `protobuf:"bytes,7,rep,name=policy_names,json=policyNames,proto3" json:"policy_names,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -165,6 +166,13 @@ func (x *Identity) GetServiceAccountIds() []string { return nil } +func (x *Identity) GetPolicyNames() []string { + if x != nil { + return x.PolicyNames + } + return nil +} + type Credential struct { state protoimpl.MessageState `protogen:"open.v1"` AccessKey string `protobuf:"bytes,1,opt,name=access_key,json=accessKey,proto3" json:"access_key,omitempty"` @@ -405,14 +413,15 @@ const file_iam_proto_rawDesc = "" + "identities\x18\x01 \x03(\v2\x10.iam_pb.IdentityR\n" + "identities\x12+\n" + "\baccounts\x18\x02 \x03(\v2\x0f.iam_pb.AccountR\baccounts\x12A\n" + - "\x10service_accounts\x18\x03 \x03(\v2\x16.iam_pb.ServiceAccountR\x0fserviceAccounts\"\xe5\x01\n" + + "\x10service_accounts\x18\x03 \x03(\v2\x16.iam_pb.ServiceAccountR\x0fserviceAccounts\"\x88\x02\n" + "\bIdentity\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x124\n" + "\vcredentials\x18\x02 \x03(\v2\x12.iam_pb.CredentialR\vcredentials\x12\x18\n" + "\aactions\x18\x03 \x03(\tR\aactions\x12)\n" + "\aaccount\x18\x04 \x01(\v2\x0f.iam_pb.AccountR\aaccount\x12\x1a\n" + "\bdisabled\x18\x05 \x01(\bR\bdisabled\x12.\n" + - "\x13service_account_ids\x18\x06 \x03(\tR\x11serviceAccountIds\"b\n" + + "\x13service_account_ids\x18\x06 \x03(\tR\x11serviceAccountIds\x12!\n" + + "\fpolicy_names\x18\a \x03(\tR\vpolicyNames\"b\n" + "\n" + "Credential\x12\x1d\n" + "\n" + diff --git a/weed/pb/master_pb/master.pb.go b/weed/pb/master_pb/master.pb.go index 8f0a54351..fc2ceec4c 100644 --- a/weed/pb/master_pb/master.pb.go +++ b/weed/pb/master_pb/master.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: master.proto package master_pb diff --git a/weed/pb/master_pb/master_grpc.pb.go b/weed/pb/master_pb/master_grpc.pb.go index ad8ac920d..441c2ffb1 100644 --- a/weed/pb/master_pb/master_grpc.pb.go +++ b/weed/pb/master_pb/master_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: master.proto package master_pb diff --git a/weed/pb/mount_pb/mount.pb.go b/weed/pb/mount_pb/mount.pb.go index 0d9fde354..1299f6bec 100644 --- a/weed/pb/mount_pb/mount.pb.go +++ b/weed/pb/mount_pb/mount.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: mount.proto package mount_pb diff --git a/weed/pb/mount_pb/mount_grpc.pb.go b/weed/pb/mount_pb/mount_grpc.pb.go index 599a8807a..6a1281326 100644 --- a/weed/pb/mount_pb/mount_grpc.pb.go +++ b/weed/pb/mount_pb/mount_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: mount.proto package mount_pb diff --git a/weed/pb/mq_agent_pb/mq_agent.pb.go b/weed/pb/mq_agent_pb/mq_agent.pb.go index bc321e957..155819280 100644 --- a/weed/pb/mq_agent_pb/mq_agent.pb.go +++ b/weed/pb/mq_agent_pb/mq_agent.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: mq_agent.proto package mq_agent_pb diff --git a/weed/pb/mq_agent_pb/mq_agent_grpc.pb.go b/weed/pb/mq_agent_pb/mq_agent_grpc.pb.go index 5b020bd73..62e323d13 100644 --- a/weed/pb/mq_agent_pb/mq_agent_grpc.pb.go +++ b/weed/pb/mq_agent_pb/mq_agent_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: mq_agent.proto package mq_agent_pb diff --git a/weed/pb/mq_pb/mq_broker.pb.go b/weed/pb/mq_pb/mq_broker.pb.go index 7e7f706cb..88affa163 100644 --- a/weed/pb/mq_pb/mq_broker.pb.go +++ b/weed/pb/mq_pb/mq_broker.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: mq_broker.proto package mq_pb diff --git a/weed/pb/mq_pb/mq_broker_grpc.pb.go b/weed/pb/mq_pb/mq_broker_grpc.pb.go index 77ff7df52..1abefb75b 100644 --- a/weed/pb/mq_pb/mq_broker_grpc.pb.go +++ b/weed/pb/mq_pb/mq_broker_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: mq_broker.proto package mq_pb diff --git a/weed/pb/remote_pb/remote.pb.go b/weed/pb/remote_pb/remote.pb.go index 8ca391d3d..f7b3866c5 100644 --- a/weed/pb/remote_pb/remote.pb.go +++ b/weed/pb/remote_pb/remote.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: remote.proto package remote_pb diff --git a/weed/pb/s3_pb/s3.pb.go b/weed/pb/s3_pb/s3.pb.go index 31b6c8e2e..67d6ef11d 100644 --- a/weed/pb/s3_pb/s3.pb.go +++ b/weed/pb/s3_pb/s3.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: s3.proto package s3_pb diff --git a/weed/pb/s3_pb/s3_grpc.pb.go b/weed/pb/s3_pb/s3_grpc.pb.go index 91f3138ce..f5f43f54f 100644 --- a/weed/pb/s3_pb/s3_grpc.pb.go +++ b/weed/pb/s3_pb/s3_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: s3.proto package s3_pb diff --git a/weed/pb/schema_pb/mq_schema.pb.go b/weed/pb/schema_pb/mq_schema.pb.go index 7fbf4a4e6..45d04c7bd 100644 --- a/weed/pb/schema_pb/mq_schema.pb.go +++ b/weed/pb/schema_pb/mq_schema.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: mq_schema.proto package schema_pb diff --git a/weed/pb/volume_server_pb/volume_server.pb.go b/weed/pb/volume_server_pb/volume_server.pb.go index 27e791be5..7b174f8dd 100644 --- a/weed/pb/volume_server_pb/volume_server.pb.go +++ b/weed/pb/volume_server_pb/volume_server.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: volume_server.proto package volume_server_pb diff --git a/weed/pb/volume_server_pb/volume_server_grpc.pb.go b/weed/pb/volume_server_pb/volume_server_grpc.pb.go index f43cff84c..02818ab38 100644 --- a/weed/pb/volume_server_pb/volume_server_grpc.pb.go +++ b/weed/pb/volume_server_pb/volume_server_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: volume_server.proto package volume_server_pb diff --git a/weed/pb/worker_pb/worker.pb.go b/weed/pb/worker_pb/worker.pb.go index 7ff5a8a36..f0bd661c1 100644 --- a/weed/pb/worker_pb/worker.pb.go +++ b/weed/pb/worker_pb/worker.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc v6.33.1 // source: worker.proto package worker_pb diff --git a/weed/pb/worker_pb/worker_grpc.pb.go b/weed/pb/worker_pb/worker_grpc.pb.go index 85bad96f4..0b0a9ad09 100644 --- a/weed/pb/worker_pb/worker_grpc.pb.go +++ b/weed/pb/worker_pb/worker_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v5.29.3 +// - protoc v6.33.1 // source: worker.proto package worker_pb diff --git a/weed/s3api/auth_credentials.go b/weed/s3api/auth_credentials.go index 9076dc54f..57918fabe 100644 --- a/weed/s3api/auth_credentials.go +++ b/weed/s3api/auth_credentials.go @@ -66,8 +66,9 @@ type Identity struct { Account *Account Credentials []*Credential Actions []Action - PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username") - Disabled bool // User status: false = enabled (default), true = disabled + PolicyNames []string // Attached IAM policy names + PrincipalArn string // ARN for IAM authorization (e.g., "arn:aws:iam::account-id:user/username") + Disabled bool // User status: false = enabled (default), true = disabled } // Account represents a system user, a system user can @@ -310,6 +311,7 @@ func (iam *IdentityAccessManagement) loadS3ApiConfiguration(config *iam_pb.S3Api Actions: nil, PrincipalArn: generatePrincipalArn(ident.Name), Disabled: ident.Disabled, // false (default) = enabled, true = disabled + PolicyNames: ident.PolicyNames, } switch { case ident.Name == AccountAnonymous.Id: @@ -939,9 +941,10 @@ func (iam *IdentityAccessManagement) authenticateJWTWithIAM(r *http.Request) (*I // Convert IAMIdentity to existing Identity structure identity := &Identity{ - Name: iamIdentity.Name, - Account: iamIdentity.Account, - Actions: []Action{}, // Empty - authorization handled by policy engine + Name: iamIdentity.Name, + Account: iamIdentity.Account, + Actions: []Action{}, // Empty - authorization handled by policy engine + PolicyNames: iamIdentity.PolicyNames, } // Store session info in request headers for later authorization @@ -997,8 +1000,9 @@ func (iam *IdentityAccessManagement) authorizeWithIAM(r *http.Request, identity // Create IAMIdentity for authorization iamIdentity := &IAMIdentity{ - Name: identity.Name, - Account: identity.Account, + Name: identity.Name, + Account: identity.Account, + PolicyNames: identity.PolicyNames, } // Determine authorization path and configure identity diff --git a/weed/s3api/s3_iam_middleware.go b/weed/s3api/s3_iam_middleware.go index 22e7b2233..70f74508b 100644 --- a/weed/s3api/s3_iam_middleware.go +++ b/weed/s3api/s3_iam_middleware.go @@ -193,6 +193,7 @@ func (s3iam *S3IAMIntegration) AuthorizeAction(ctx context.Context, identity *IA Resource: resourceArn, SessionToken: identity.SessionToken, RequestContext: requestContext, + PolicyNames: identity.PolicyNames, } // Check if action is allowed using our policy engine @@ -214,6 +215,7 @@ type IAMIdentity struct { Principal string SessionToken string Account *Account + PolicyNames []string } // IsAdmin checks if the identity has admin privileges @@ -490,7 +492,8 @@ func (enhanced *EnhancedS3ApiServer) AuthenticateJWTRequest(r *http.Request) (*I Name: iamIdentity.Name, Account: iamIdentity.Account, // Note: Actions will be determined by policy evaluation - Actions: []Action{}, // Empty - authorization handled by policy engine + Actions: []Action{}, // Empty - authorization handled by policy engine + PolicyNames: iamIdentity.PolicyNames, } // Store session token for later authorization