mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-05 03:27:42 +01:00
s3: expose ReadAcp/WriteAcp in shell and admin UI, log ACL-upload denials (#12396)
* shell: accept ReadAcp and WriteAcp in per-bucket and service account actions s3.bucket.access, s3.anonymous.set and s3.serviceaccount.create rejected ReadAcp and WriteAcp, so the ACL actions the S3 gateway enforces could not be granted through them. Share one canonical action table across the three commands and list the ACL actions in their help. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: list ReadAcp and WriteAcp in s3.configure and s3.circuitBreaker help The -actions usage of both commands stopped at Admin, so the ACL actions the gateway enforces could only be found in the source. s3.circuitBreaker now prints the actions it actually accepts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: offer ReadAcp and WriteAcp in the object store user permissions The create and edit user forms only offered Read, Write, List, Tagging and Admin among the bucket permissions, so an identity could not be given the ACL actions from the UI even though uploads with x-amz-acl need WriteAcp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: log the WriteAcp denial of an upload that sets an ACL An upload or copy that carries x-amz-acl or an x-amz-grant-* header also needs WriteAcp. When that check denies, the client only sees a bare AccessDenied and nothing reached the log at the default verbosity, so the header was never suspected. Log the identity and the missing action. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: quote the key in the ACL upload denial log An object key can carry a decoded newline, which let a caller add forged lines to the default log. Quote the bucket and key, and word the message so it also fits a policy deny, not only a missing grant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: say ACL uploads need both Write and WriteAcp The s3.configure example granted WriteAcp alone, which still fails the upload's Write check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: parse S3 action lists with one shared helper s3.bucket.access, s3.anonymous.set and s3.serviceaccount.create each had their own validation loop, so a test of the table did not cover the parsing the commands run. They now call canonicalActionList, which skips empty entries and repeats. s3.anonymous.set validates before contacting IAM, and s3.bucket.access still rejects a list with no actions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep bucket-scoped actions the user form cannot show When a user has both global and bucket-scoped actions, the edit form shows only the global ones, and saving replaced the identity's actions with what the form rebuilt, deleting the scoped grants. Carry those actions through the save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: list hidden bucket grants in the user edit form Restoring the bucket-scoped grants the form cannot show kept them active even when an admin narrowed the user's permissions, with no way to see or drop them. Show them as checked boxes under Other Bucket Permissions; unchecking one removes it on save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: reject an s3.anonymous.set -access list with no actions A blank list such as ",," parsed to nothing and cleared the bucket's anonymous grants. Use "none" for that, as s3.bucket.access does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: skip the ACL upload denial log for anonymous requests Anonymous uploads with ACL headers can be sent by anyone, so logging each denial at the default level lets unauthenticated clients flood the log. Keep the line for named identities. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: count kept bucket grants when checking an edited user for permissions The empty check ran before the checked Other Bucket Permissions were merged, so a user left with only those grants could not be saved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: list bucket grants of Admin users in the user edit form parseBucketPermissions returned before reading scoped actions when the user had Admin, so removing Admin and saving dropped grants such as WriteAcp:bucket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep unpaired bucket grants as separate checkboxes in the user edit form The form saves every selected permission on every selected bucket, so grants such as ReadAcp:a with WriteAcp:b came back as all four pairs. Show the grants through the picker only when they cover that full product; otherwise list each grant on its own. Opening the form also resets the bucket scope left over from the previous user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep grants the edit pickers have no option for A permission x bucket product was shown through the pickers even when a bucket was not in the bucket list (deleted, prefix or wildcard grants) or a permission had no picker option (DeleteBucket, PutBucketQuota). Saving then dropped those grants. Such grants now go to the checkbox list, global ones included, and are submitted unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: name the s3.bucket.access anonymous example by what it grants Read,Write,List leaves out ReadAcp and WriteAcp, so it is not full access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep unlisted global grants of Admin users in the user edit form parseBucketPermissions returned for Admin before splitting global grants into picker options and the rest, and only raw option values were re-selected. Saving an Admin user dropped s3tables:* and grants with no picker option such as DeleteBucket. Admin users now use the same split. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep the /* suffix of bucket grants in the user edit form parseBucketPermissions dropped a trailing /* before matching bucket options, so saving rewrote ReadAcp:bucket/* as ReadAcp:bucket. The bare form also matches bucket-level requests such as GET ?acl, so the save widened the grant. A /* bucket is not a picker option, so the grant now stays in the checkbox list as written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: test that s3.bucket.access saves ReadAcp and WriteAcp grants Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c437791991
commit
3cbace2471
9 files changed
+219
-88
No files matched your search
@@ -230,6 +230,8 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
<option value="Write">Write</option>
|
||||
<option value="List">List</option>
|
||||
<option value="Tagging">Tagging</option>
|
||||
<option value="ReadAcp" title="Needed for GET ?acl on buckets and objects">Read ACL (ReadAcp)</option>
|
||||
<option value="WriteAcp" title="Needed for PUT ?acl and for uploads that send x-amz-acl or x-amz-grant-* headers">Write ACL (WriteAcp)</option>
|
||||
<optgroup label="Object Lock Permissions">
|
||||
<option value="BypassGovernanceRetention">Bypass Governance Retention</option>
|
||||
<option value="GetObjectRetention">Get Object Retention</option>
|
||||
@@ -342,6 +344,8 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
<option value="Write">Write</option>
|
||||
<option value="List">List</option>
|
||||
<option value="Tagging">Tagging</option>
|
||||
<option value="ReadAcp" title="Needed for GET ?acl on buckets and objects">Read ACL (ReadAcp)</option>
|
||||
<option value="WriteAcp" title="Needed for PUT ?acl and for uploads that send x-amz-acl or x-amz-grant-* headers">Write ACL (WriteAcp)</option>
|
||||
<optgroup label="Object Lock Permissions">
|
||||
<option value="BypassGovernanceRetention">Bypass Governance Retention</option>
|
||||
<option value="GetObjectRetention">Get Object Retention</option>
|
||||
@@ -405,6 +409,11 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
<small class="form-text text-muted">Hold Ctrl/Cmd to select multiple buckets</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" id="editUnshownActionsGroup" style="display: none;">
|
||||
<label class="form-label">Other Bucket Permissions</label>
|
||||
<small class="form-text text-muted d-block mb-2">Grants the permission and bucket pickers above cannot show. Uncheck a grant to remove it.</small>
|
||||
<div id="editUnshownActions"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="editPolicies" class="form-label">Attached Policies</label>
|
||||
<select multiple class="form-control" id="editPolicies" name="policies" size="5">
|
||||
@@ -739,15 +748,10 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
isAdmin: false,
|
||||
permissions: [],
|
||||
applyToAll: false,
|
||||
specificBuckets: []
|
||||
specificBuckets: [],
|
||||
unshownActions: []
|
||||
};
|
||||
|
||||
// Check if user has Admin permission
|
||||
if (actions.includes('Admin')) {
|
||||
result.isAdmin = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Separate bucket-scoped from global actions
|
||||
const bucketActions = [];
|
||||
const globalBucketPerms = [];
|
||||
@@ -756,44 +760,67 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
if (action.startsWith('s3tables:')) {
|
||||
const actionValue = action.slice('s3tables:'.length);
|
||||
if (actionValue === '*') {
|
||||
globalBucketPerms.push('S3TablesAdmin');
|
||||
globalBucketPerms.push({ permission: 'S3TablesAdmin', action: action });
|
||||
return;
|
||||
}
|
||||
const parts = actionValue.split(':');
|
||||
const perm = parts[0];
|
||||
const bucket = parts.length > 1 ? parts.slice(1).join(':').replace(/\/\*$/, '') : '';
|
||||
const bucket = parts.length > 1 ? parts.slice(1).join(':') : '';
|
||||
if (bucket) {
|
||||
bucketActions.push({ permission: perm, bucketId: 's3tables:' + bucket });
|
||||
bucketActions.push({ permission: perm, bucketId: 's3tables:' + bucket, action: action });
|
||||
} else {
|
||||
globalBucketPerms.push(perm);
|
||||
globalBucketPerms.push({ permission: perm, action: action });
|
||||
}
|
||||
} else if (action.includes(':')) {
|
||||
const parts = action.split(':');
|
||||
const perm = parts[0];
|
||||
const bucket = parts.slice(1).join(':').replace(/\/\*$/, '');
|
||||
bucketActions.push({ permission: perm, bucketId: 's3:' + bucket });
|
||||
const bucket = parts.slice(1).join(':');
|
||||
bucketActions.push({ permission: perm, bucketId: 's3:' + bucket, action: action });
|
||||
} else {
|
||||
globalBucketPerms.push(action);
|
||||
globalBucketPerms.push({ permission: action, action: action });
|
||||
}
|
||||
});
|
||||
|
||||
const listed = globalBucketPerms.filter(gp => hasPickerOption('editActions', gp.permission));
|
||||
const unlisted = globalBucketPerms.filter(gp => !listed.includes(gp)).map(gp => gp.action);
|
||||
|
||||
if (actions.includes('Admin')) {
|
||||
result.isAdmin = true;
|
||||
result.permissions = listed.map(gp => gp.permission);
|
||||
result.unshownActions = unlisted.concat(bucketActions.map(ba => ba.action));
|
||||
return result;
|
||||
}
|
||||
|
||||
// If we have global bucket permissions (no colon), they apply to all buckets
|
||||
if (globalBucketPerms.length > 0) {
|
||||
result.permissions = globalBucketPerms;
|
||||
result.permissions = listed.map(gp => gp.permission);
|
||||
result.applyToAll = true;
|
||||
result.unshownActions = unlisted.concat(bucketActions.map(ba => ba.action));
|
||||
} else if (bucketActions.length > 0) {
|
||||
// Get unique permissions and buckets
|
||||
// The form saves every permission on every selected bucket, so it can
|
||||
// show the grants only when they cover that full product with listed options.
|
||||
const perms = [...new Set(bucketActions.map(ba => ba.permission))];
|
||||
const buckets = [...new Set(bucketActions.map(ba => ba.bucketId))];
|
||||
const pairs = new Set(bucketActions.map(ba => JSON.stringify([ba.permission, ba.bucketId])));
|
||||
const allListed = perms.every(perm => hasPickerOption('editActions', perm)) &&
|
||||
buckets.every(bucketId => hasPickerOption('editSelectedBuckets', bucketId));
|
||||
|
||||
result.permissions = perms;
|
||||
result.applyToAll = false;
|
||||
result.specificBuckets = buckets;
|
||||
if (allListed && pairs.size === perms.length * buckets.length) {
|
||||
result.permissions = perms;
|
||||
result.specificBuckets = buckets;
|
||||
} else {
|
||||
result.unshownActions = bucketActions.map(ba => ba.action);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function hasPickerOption(selectId, value) {
|
||||
const select = document.getElementById(selectId);
|
||||
return !!select && Array.from(select.options).some(option => option.value === value);
|
||||
}
|
||||
|
||||
function parseBucketOptionValue(value) {
|
||||
if (value.startsWith('s3tables:')) {
|
||||
return { type: 's3tables', name: value.slice('s3tables:'.length) };
|
||||
@@ -804,6 +831,32 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
return { type: 's3', name: value };
|
||||
}
|
||||
|
||||
function renderUnshownActions(actions) {
|
||||
const container = document.getElementById('editUnshownActions');
|
||||
container.replaceChildren();
|
||||
actions.forEach((action, i) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'form-check';
|
||||
const input = document.createElement('input');
|
||||
input.className = 'form-check-input';
|
||||
input.type = 'checkbox';
|
||||
input.id = 'editUnshownAction' + i;
|
||||
input.value = action;
|
||||
input.checked = true;
|
||||
const label = document.createElement('label');
|
||||
label.className = 'form-check-label';
|
||||
label.htmlFor = input.id;
|
||||
label.textContent = action;
|
||||
wrapper.append(input, label);
|
||||
container.appendChild(wrapper);
|
||||
});
|
||||
document.getElementById('editUnshownActionsGroup').style.display = actions.length > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
function selectedUnshownActions() {
|
||||
return Array.from(document.querySelectorAll('#editUnshownActions input:checked')).map(input => input.value);
|
||||
}
|
||||
|
||||
// Build bucket permission action strings using original permissions dropdown
|
||||
/**
|
||||
* Builds bucket permission strings based on selected permissions and bucket scope.
|
||||
@@ -943,6 +996,12 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
const user = await response.json();
|
||||
|
||||
// Populate edit form
|
||||
renderUnshownActions([]);
|
||||
document.getElementById('editAllBuckets').checked = true;
|
||||
Array.from(document.getElementById('editSelectedBuckets').options).forEach(option => {
|
||||
option.selected = false;
|
||||
});
|
||||
toggleBucketList('edit');
|
||||
document.getElementById('editUsername').value = username;
|
||||
document.getElementById('editEmail').value = user.email || '';
|
||||
|
||||
@@ -963,14 +1022,13 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
// Populate bucket permissions using original permissions dropdown
|
||||
if (user.actions && user.actions.length > 0) {
|
||||
const bucketPerms = parseBucketPermissions(user.actions);
|
||||
renderUnshownActions(bucketPerms.unshownActions);
|
||||
|
||||
// Set permissions in the original multi-select
|
||||
const actionsSelect = document.getElementById('editActions');
|
||||
if (actionsSelect) {
|
||||
Array.from(actionsSelect.options).forEach(option => {
|
||||
if (bucketPerms.isAdmin && option.value === 'Admin') {
|
||||
option.selected = true;
|
||||
} else if (!bucketPerms.isAdmin && bucketPerms.permissions.includes(option.value)) {
|
||||
if (bucketPerms.permissions.includes(option.value)) {
|
||||
option.selected = true;
|
||||
}
|
||||
});
|
||||
@@ -1232,15 +1290,15 @@ templ ObjectStoreUsers(data dash.ObjectStoreUsersData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that permissions are not empty
|
||||
if (!allActions || allActions.length === 0) {
|
||||
const actions = [...new Set([...allActions, ...selectedUnshownActions()])];
|
||||
if (actions.length === 0) {
|
||||
showAlert('At least one permission must be selected', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = {
|
||||
email: document.getElementById('editEmail').value,
|
||||
actions: allActions,
|
||||
actions: actions,
|
||||
policy_names: Array.from(document.getElementById('editPolicies').selectedOptions).map(option => option.value)
|
||||
};
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ func ObjectStoreUsers(data dash.ObjectStoreUsersData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\" size=\"10\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option> <optgroup label=\"Object Lock Permissions\"><option value=\"BypassGovernanceRetention\">Bypass Governance Retention</option> <option value=\"GetObjectRetention\">Get Object Retention</option> <option value=\"PutObjectRetention\">Put Object Retention</option> <option value=\"GetObjectLegalHold\">Get Object Legal Hold</option> <option value=\"PutObjectLegalHold\">Put Object Legal Hold</option> <option value=\"GetBucketObjectLockConfiguration\">Get Bucket Object Lock Configuration</option> <option value=\"PutBucketObjectLockConfiguration\">Put Bucket Object Lock Configuration</option></optgroup> <optgroup label=\"Bucket Policy Permissions\"><option value=\"PutBucketPolicy\">Put Bucket Policy</option> <option value=\"DeleteBucketPolicy\">Delete Bucket Policy</option></optgroup> <optgroup label=\"S3 Tables Permissions\"><option value=\"S3TablesAdmin\">S3 Tables Admin (Full Access)</option> <option value=\"CreateTableBucket\">Create Table Bucket</option> <option value=\"GetTableBucket\">Get Table Bucket</option> <option value=\"ListTableBuckets\">List Table Buckets</option> <option value=\"DeleteTableBucket\">Delete Table Bucket</option> <option value=\"PutTableBucketPolicy\">Put Table Bucket Policy</option> <option value=\"GetTableBucketPolicy\">Get Table Bucket Policy</option> <option value=\"DeleteTableBucketPolicy\">Delete Table Bucket Policy</option> <option value=\"CreateNamespace\">Create Namespace</option> <option value=\"GetNamespace\">Get Namespace</option> <option value=\"ListNamespaces\">List Namespaces</option> <option value=\"DeleteNamespace\">Delete Namespace</option> <option value=\"CreateTable\">Create Table</option> <option value=\"GetTable\">Get Table</option> <option value=\"ListTables\">List Tables</option> <option value=\"DeleteTable\">Delete Table</option> <option value=\"PutTablePolicy\">Put Table Policy</option> <option value=\"GetTablePolicy\">Get Table Policy</option> <option value=\"DeleteTablePolicy\">Delete Table Policy</option> <option value=\"TagResource\">Tag Resource</option> <option value=\"ListTagsForResource\">List Tags</option> <option value=\"UntagResource\">Untag Resource</option></optgroup></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3\"><label class=\"form-label\">Bucket Scope</label> <small class=\"form-text text-muted d-block mb-2\">Apply selected permissions to specific buckets or all buckets</small><div class=\"form-check mb-2\"><input class=\"form-check-input\" type=\"radio\" name=\"bucketScope\" id=\"allBuckets\" value=\"all\" checked onchange=\"toggleBucketList()\"> <label class=\"form-check-label\" for=\"allBuckets\">All Buckets</label></div><div class=\"form-check mb-2\"><input class=\"form-check-input\" type=\"radio\" name=\"bucketScope\" id=\"specificBuckets\" value=\"specific\" onchange=\"toggleBucketList()\"> <label class=\"form-check-label\" for=\"specificBuckets\">Specific Buckets</label></div><div id=\"bucketSelectionList\" class=\"mt-2\" style=\"display: none;\"><select multiple class=\"form-select\" id=\"selectedBuckets\" size=\"5\"><!-- Options loaded dynamically --></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple buckets</small></div></div><div class=\"mb-3\"><label for=\"policies\" class=\"form-label\">Attached Policies</label> <select multiple class=\"form-control\" id=\"policies\" name=\"policies\" size=\"5\"><!-- Options loaded dynamically --></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple policies</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUserModal\" tabindex=\"-1\" role=\"dialog\"><div class=\"modal-dialog\" role=\"document\"><div class=\"modal-content\"><div class=\"modal-header\"><h5 class=\"modal-title\"><i class=\"bi bi-person-gear me-2\"></i>Edit User</h5><button type=\"button\" class=\Line truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</div><div class=\"mb-3\"><label for=\"email\" class=\"form-label\">Email</label> <input type=\"email\" class=\"form-control\" id=\"email\" name=\"email\"></div><div class=\"mb-3\"><label for=\"actions\" class=\"form-label\">Permissions</label> <select multiple class=\"form-control\" id=\"actions\" name=\"actions\" size=\"10\"><option value=\"Admin\">Admin (Full Access)</option> <option value=\"Read\">Read</option> <option value=\"Write\">Write</option> <option value=\"List\">List</option> <option value=\"Tagging\">Tagging</option> <option value=\"ReadAcp\" title=\"Needed for GET ?acl on buckets and objects\">Read ACL (ReadAcp)</option> <option value=\"WriteAcp\" title=\"Needed for PUT ?acl and for uploads that send x-amz-acl or x-amz-grant-* headers\">Write ACL (WriteAcp)</option> <optgroup label=\"Object Lock Permissions\"><option value=\"BypassGovernanceRetention\">Bypass Governance Retention</option> <option value=\"GetObjectRetention\">Get Object Retention</option> <option value=\"PutObjectRetention\">Put Object Retention</option> <option value=\"GetObjectLegalHold\">Get Object Legal Hold</option> <option value=\"PutObjectLegalHold\">Put Object Legal Hold</option> <option value=\"GetBucketObjectLockConfiguration\">Get Bucket Object Lock Configuration</option> <option value=\"PutBucketObjectLockConfiguration\">Put Bucket Object Lock Configuration</option></optgroup> <optgroup label=\"Bucket Policy Permissions\"><option value=\"PutBucketPolicy\">Put Bucket Policy</option> <option value=\"DeleteBucketPolicy\">Delete Bucket Policy</option></optgroup> <optgroup label=\"S3 Tables Permissions\"><option value=\"S3TablesAdmin\">S3 Tables Admin (Full Access)</option> <option value=\"CreateTableBucket\">Create Table Bucket</option> <option value=\"GetTableBucket\">Get Table Bucket</option> <option value=\"ListTableBuckets\">List Table Buckets</option> <option value=\"DeleteTableBucket\">Delete Table Bucket</option> <option value=\"PutTableBucketPolicy\">Put Table Bucket Policy</option> <option value=\"GetTableBucketPolicy\">Get Table Bucket Policy</option> <option value=\"DeleteTableBucketPolicy\">Delete Table Bucket Policy</option> <option value=\"CreateNamespace\">Create Namespace</option> <option value=\"GetNamespace\">Get Namespace</option> <option value=\"ListNamespaces\">List Namespaces</option> <option value=\"DeleteNamespace\">Delete Namespace</option> <option value=\"CreateTable\">Create Table</option> <option value=\"GetTable\">Get Table</option> <option value=\"ListTables\">List Tables</option> <option value=\"DeleteTable\">Delete Table</option> <option value=\"PutTablePolicy\">Put Table Policy</option> <option value=\"GetTablePolicy\">Get Table Policy</option> <option value=\"DeleteTablePolicy\">Delete Table Policy</option> <option value=\"TagResource\">Tag Resource</option> <option value=\"ListTagsForResource\">List Tags</option> <option value=\"UntagResource\">Untag Resource</option></optgroup></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple permissions</small></div><div class=\"mb-3\"><label class=\"form-label\">Bucket Scope</label> <small class=\"form-text text-muted d-block mb-2\">Apply selected permissions to specific buckets or all buckets</small><div class=\"form-check mb-2\"><input class=\"form-check-input\" type=\"radio\" name=\"bucketScope\" id=\"allBuckets\" value=\"all\" checked onchange=\"toggleBucketList()\"> <label class=\"form-check-label\" for=\"allBuckets\">All Buckets</label></div><div class=\"form-check mb-2\"><input class=\"form-check-input\" type=\"radio\" name=\"bucketScope\" id=\"specificBuckets\" value=\"specific\" onchange=\"toggleBucketList()\"> <label class=\"form-check-label\" for=\"specificBuckets\">Specific Buckets</label></div><div id=\"bucketSelectionList\" class=\"mt-2\" style=\"display: none;\"><select multiple class=\"form-select\" id=\"selectedBuckets\" size=\"5\"><!-- Options loaded dynamically --></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple buckets</small></div></div><div class=\"mb-3\"><label for=\"policies\" class=\"form-label\">Attached Policies</label> <select multiple class=\"form-control\" id=\"policies\" name=\"policies\" size=\"5\"><!-- Options loaded dynamically --></select> <small class=\"form-text text-muted\">Hold Ctrl/Cmd to select multiple policies</small></div><div class=\"mb-3 form-check\"><input type=\"checkbox\" class=\"form-check-input\" id=\"generateKey\" name=\"generateKey\" checked> <label class=\"form-check-label\" for=\"generateKey\">Generate access key automatically</label></div></form></div><div class=\"modal-footer\"><button type=\"button\" class=\"btn btn-secondary\" data-bs-dismiss=\"modal\">Cancel</button> <button type=\"button\" class=\"btn btn-primary\" onclick=\"handleCreateUser()\">Create User</button></div></div></div></div><!-- Edit User Modal --><div class=\"modal fade\" id=\"editUseLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/seaweedfs/seaweedfs/weed/glog"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
@@ -156,8 +157,12 @@ func (s3a *S3ApiServer) preparePutObjectACL(r *http.Request, bucket string) (*ht
|
||||
// Setting an ACL during upload or copy also requires s3:PutObjectAcl. Use the
|
||||
// unified authorization path so bucket-policy allows and explicit denies
|
||||
// retain the same semantics as standalone ACL requests.
|
||||
identity, authCode := s3a.iam.authRequest(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP)
|
||||
identity, authCode, _ := s3a.iam.authRequestWithAuthType(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP)
|
||||
if authCode != s3err.ErrNone {
|
||||
if authCode == s3err.ErrAccessDenied && identity != nil && identity.Name != s3_constants.AccountAnonymousId {
|
||||
_, object := s3_constants.GetBucketAndObject(r)
|
||||
glog.Infof("denied %s %q for %q: the request sets an ACL and failed the %s (s3:PutObjectAcl) check", r.Method, bucket+"/"+object, identity.Name, s3_constants.ACTION_WRITE_ACP)
|
||||
}
|
||||
return r, authCode
|
||||
}
|
||||
if identity == nil || identity.Account == nil {
|
||||
|
||||
@@ -31,7 +31,7 @@ func (c *commandS3AnonymousSet) Help() string {
|
||||
s3.anonymous.set -bucket <bucket_name> -access Read,List
|
||||
s3.anonymous.set -bucket <bucket_name> -access none
|
||||
|
||||
Supported actions: Read, Write, List, Tagging, Admin
|
||||
Supported actions: Read, ReadAcp, Write, WriteAcp, List, Tagging, Admin
|
||||
Use "none" to remove all anonymous access for the bucket.
|
||||
|
||||
This manages the special "anonymous" user's actions. It does not
|
||||
@@ -46,7 +46,7 @@ func (c *commandS3AnonymousSet) HasTag(CommandTag) bool {
|
||||
func (c *commandS3AnonymousSet) Do(args []string, commandEnv *CommandEnv, writer io.Writer) error {
|
||||
f := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
|
||||
bucket := f.String("bucket", "", "bucket name")
|
||||
access := f.String("access", "", "comma-separated actions: Read,Write,List,Tagging,Admin or none")
|
||||
access := f.String("access", "", "comma-separated actions: Read,ReadAcp,Write,WriteAcp,List,Tagging,Admin or none")
|
||||
if err := f.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -57,6 +57,16 @@ func (c *commandS3AnonymousSet) Do(args []string, commandEnv *CommandEnv, writer
|
||||
if *access == "" {
|
||||
return fmt.Errorf("-access is required")
|
||||
}
|
||||
var granted []string
|
||||
if strings.ToLower(strings.TrimSpace(*access)) != "none" {
|
||||
var err error
|
||||
if granted, err = canonicalActionList(*access); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(granted) == 0 {
|
||||
return fmt.Errorf("invalid -access %q: use %s, or none", *access, supportedActionNames)
|
||||
}
|
||||
}
|
||||
|
||||
return commandEnv.withIamClient(func(ctx context.Context, client iam_pb.SeaweedIdentityAccessManagementClient) error {
|
||||
// Get or create anonymous user
|
||||
@@ -74,27 +84,8 @@ func (c *commandS3AnonymousSet) Do(args []string, commandEnv *CommandEnv, writer
|
||||
}
|
||||
}
|
||||
|
||||
// Add new actions unless "none"
|
||||
canonicalActions := map[string]string{
|
||||
"read": "Read", "write": "Write", "list": "List",
|
||||
"tagging": "Tagging", "admin": "Admin",
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(*access)) != "none" {
|
||||
seen := make(map[string]struct{})
|
||||
for _, action := range strings.Split(*access, ",") {
|
||||
action = strings.TrimSpace(action)
|
||||
if action != "" {
|
||||
canonical, ok := canonicalActions[strings.ToLower(action)]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid action %q: supported actions are Read, Write, List, Tagging, Admin", action)
|
||||
}
|
||||
if _, dup := seen[canonical]; dup {
|
||||
continue
|
||||
}
|
||||
seen[canonical] = struct{}{}
|
||||
kept = append(kept, canonical+":"+*bucket)
|
||||
}
|
||||
}
|
||||
for _, action := range granted {
|
||||
kept = append(kept, action+":"+*bucket)
|
||||
}
|
||||
|
||||
identity.Actions = kept
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -17,11 +18,35 @@ import (
|
||||
|
||||
// canonicalActions maps lowercased action names to their canonical form.
|
||||
var canonicalActions = map[string]string{
|
||||
"read": "Read",
|
||||
"write": "Write",
|
||||
"list": "List",
|
||||
"tagging": "Tagging",
|
||||
"admin": "Admin",
|
||||
"read": "Read",
|
||||
"readacp": "ReadAcp",
|
||||
"write": "Write",
|
||||
"writeacp": "WriteAcp",
|
||||
"list": "List",
|
||||
"tagging": "Tagging",
|
||||
"admin": "Admin",
|
||||
}
|
||||
|
||||
const supportedActionNames = "Read, ReadAcp, Write, WriteAcp, List, Tagging, Admin"
|
||||
|
||||
// canonicalActionList parses a comma-separated action list into canonical
|
||||
// names, skipping empty entries and repeats.
|
||||
func canonicalActionList(list string) ([]string, error) {
|
||||
var actions []string
|
||||
for _, a := range strings.Split(list, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
if a == "" {
|
||||
continue
|
||||
}
|
||||
canonical, ok := canonicalActions[strings.ToLower(a)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid action %q: supported actions are %s", a, supportedActionNames)
|
||||
}
|
||||
if !slices.Contains(actions, canonical) {
|
||||
actions = append(actions, canonical)
|
||||
}
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -45,14 +70,16 @@ func (c *commandS3BucketAccess) Help() string {
|
||||
# Grant anonymous read and list access
|
||||
s3.bucket.access -name <bucket_name> -user anonymous -access Read,List
|
||||
|
||||
# Grant full anonymous access
|
||||
# Grant anonymous read, write and list access
|
||||
s3.bucket.access -name <bucket_name> -user anonymous -access Read,Write,List
|
||||
|
||||
# Remove all access for a user on a bucket
|
||||
s3.bucket.access -name <bucket_name> -user <username> -access none
|
||||
|
||||
Supported action names (comma-separated):
|
||||
Read, Write, List, Tagging, Admin
|
||||
Read, ReadAcp, Write, WriteAcp, List, Tagging, Admin
|
||||
ReadAcp covers GET ?acl and WriteAcp covers PUT ?acl. An upload
|
||||
that carries an ACL header such as x-amz-acl needs Write and WriteAcp.
|
||||
|
||||
The user is auto-created if it does not exist. Actions are scoped to
|
||||
the specified bucket (stored as "Action:bucket" in the identity).
|
||||
@@ -68,7 +95,7 @@ func (c *commandS3BucketAccess) Do(args []string, commandEnv *CommandEnv, writer
|
||||
bucketCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
|
||||
bucketName := bucketCommand.String("name", "", "bucket name")
|
||||
userName := bucketCommand.String("user", "", "user name")
|
||||
access := bucketCommand.String("access", "", "comma-separated actions: Read,Write,List,Tagging,Admin or none")
|
||||
access := bucketCommand.String("access", "", "comma-separated actions: Read,ReadAcp,Write,WriteAcp,List,Tagging,Admin or none")
|
||||
if err = bucketCommand.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -82,16 +109,13 @@ func (c *commandS3BucketAccess) Do(args []string, commandEnv *CommandEnv, writer
|
||||
|
||||
accessStr := strings.TrimSpace(*access)
|
||||
|
||||
// Validate and normalize actions to canonical casing
|
||||
if accessStr != "" && strings.ToLower(accessStr) != "none" {
|
||||
var normalized []string
|
||||
for _, a := range strings.Split(accessStr, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
canonical, ok := canonicalActions[strings.ToLower(a)]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid action %q: must be Read, Write, List, Tagging, Admin, or none", a)
|
||||
}
|
||||
normalized = append(normalized, canonical)
|
||||
normalized, err := canonicalActionList(accessStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return fmt.Errorf("invalid -access %q: use %s, or none", accessStr, supportedActionNames)
|
||||
}
|
||||
accessStr = strings.Join(normalized, ",")
|
||||
}
|
||||
|
||||
@@ -4,11 +4,54 @@ import (
|
||||
"bytes"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/iam_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
||||
)
|
||||
|
||||
func TestCanonicalActionsAcceptAcpActions(t *testing.T) {
|
||||
names := strings.Split(supportedActionNames, ", ")
|
||||
for _, action := range []string{
|
||||
s3_constants.ACTION_READ, s3_constants.ACTION_READ_ACP,
|
||||
s3_constants.ACTION_WRITE, s3_constants.ACTION_WRITE_ACP,
|
||||
s3_constants.ACTION_LIST, s3_constants.ACTION_TAGGING, s3_constants.ACTION_ADMIN,
|
||||
} {
|
||||
for _, input := range []string{action, strings.ToLower(action), strings.ToUpper(action)} {
|
||||
if got := canonicalActions[strings.ToLower(input)]; got != action {
|
||||
t.Errorf("canonicalActions[%q] = %q, want %q", input, got, action)
|
||||
}
|
||||
}
|
||||
if !slices.Contains(names, action) {
|
||||
t.Errorf("supportedActionNames %q does not list %s", supportedActionNames, action)
|
||||
}
|
||||
}
|
||||
if len(names) != len(canonicalActions) {
|
||||
t.Errorf("supportedActionNames lists %d actions, canonicalActions has %d", len(names), len(canonicalActions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalActionList(t *testing.T) {
|
||||
got, err := canonicalActionList(" readacp, WriteAcp,,writeacp ,list")
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalActionList: %v", err)
|
||||
}
|
||||
if want := []string{"ReadAcp", "WriteAcp", "List"}; !slices.Equal(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
if _, err := canonicalActionList("Read,PutObjectAcl"); err == nil {
|
||||
t.Error("expected an error for an unsupported action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousSetRejectsEmptyAccess(t *testing.T) {
|
||||
err := (&commandS3AnonymousSet{}).Do([]string{"-bucket", "b", "-access", " , ,"}, nil, &bytes.Buffer{})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid -access") {
|
||||
t.Errorf("got %v, want an invalid -access error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketActions_SetActions(t *testing.T) {
|
||||
identity := &iam_pb.Identity{
|
||||
Name: "anonymous",
|
||||
@@ -39,6 +82,24 @@ func TestUpdateBucketActions_ReplaceActions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketActions_AcpActions(t *testing.T) {
|
||||
identity := &iam_pb.Identity{
|
||||
Name: "testuser",
|
||||
Actions: []string{"Read:my-bucket", "Write:other-bucket"},
|
||||
}
|
||||
|
||||
normalized, err := canonicalActionList("read,readacp,WRITEACP")
|
||||
if err != nil {
|
||||
t.Fatalf("canonicalActionList: %v", err)
|
||||
}
|
||||
updateBucketActions(identity, "my-bucket", strings.Join(normalized, ","))
|
||||
|
||||
expected := []string{"Write:other-bucket", "Read:my-bucket", "ReadAcp:my-bucket", "WriteAcp:my-bucket"}
|
||||
if !slices.Equal(identity.Actions, expected) {
|
||||
t.Errorf("got %v, want %v", identity.Actions, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBucketActions_None(t *testing.T) {
|
||||
identity := &iam_pb.Identity{
|
||||
Name: "anonymous",
|
||||
|
||||
@@ -65,7 +65,7 @@ func (c *commandS3CircuitBreaker) Do(args []string, commandEnv *CommandEnv, writ
|
||||
buckets := s3CircuitBreakerCommand.String("buckets", "", "the bucket name(s) to configure, eg: -buckets x,y,z")
|
||||
global := s3CircuitBreakerCommand.Bool("global", false, "configure global circuit breaker")
|
||||
|
||||
actions := s3CircuitBreakerCommand.String("actions", "", "comma separated actions names: Read,Write,List,Tagging,Admin")
|
||||
actions := s3CircuitBreakerCommand.String("actions", "", "comma separated actions names: "+strings.Join(s3_constants.AllowedActions, ","))
|
||||
limitType := s3CircuitBreakerCommand.String("type", "", "'Count' or 'MB'; Count represents the number of simultaneous requests, and MB represents the content size of all simultaneous requests")
|
||||
values := s3CircuitBreakerCommand.String("values", "", "comma separated values")
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ func (c *commandS3Configure) Help() string {
|
||||
|
||||
# create a new identity with account information
|
||||
s3.configure -user=username -actions=Read,Write,List,Tagging -buckets=bucket-name -policies=policy1,policy2 -access_key=key -secret_key=secret -account_id=id -account_display_name=name -account_email=email@example.com -apply
|
||||
|
||||
# allow uploads that send an ACL header such as x-amz-acl
|
||||
s3.configure -user=username -actions=Write,WriteAcp -buckets=bucket-name -apply
|
||||
|
||||
Common action names: Read, ReadAcp, Write, WriteAcp, List, Tagging, Admin
|
||||
ReadAcp covers GET ?acl and WriteAcp covers PUT ?acl. An upload or copy
|
||||
that carries an ACL header such as x-amz-acl needs both Write and WriteAcp.
|
||||
`
|
||||
}
|
||||
|
||||
@@ -44,7 +51,7 @@ func (c *commandS3Configure) HasTag(tag CommandTag) bool {
|
||||
func (c *commandS3Configure) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) {
|
||||
|
||||
s3ConfigureCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
|
||||
actions := s3ConfigureCommand.String("actions", "", "comma separated actions names: Read,Write,List,Tagging,Admin")
|
||||
actions := s3ConfigureCommand.String("actions", "", "comma separated actions names: Read,ReadAcp,Write,WriteAcp,List,Tagging,Admin")
|
||||
user := s3ConfigureCommand.String("user", "", "user name")
|
||||
buckets := s3ConfigureCommand.String("buckets", "", "bucket name")
|
||||
accessKey := s3ConfigureCommand.String("access_key", "", "specify the access key")
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/iam"
|
||||
@@ -84,26 +83,12 @@ func (c *commandS3ServiceAccountCreate) Do(args []string, commandEnv *CommandEnv
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
validActions := map[string]string{
|
||||
"read": "Read", "write": "Write", "list": "List",
|
||||
"tagging": "Tagging", "admin": "Admin",
|
||||
}
|
||||
if *actions != "" {
|
||||
seen := make(map[string]struct{})
|
||||
for _, a := range strings.Split(*actions, ",") {
|
||||
a = strings.TrimSpace(a)
|
||||
if a != "" {
|
||||
canonical, ok := validActions[strings.ToLower(a)]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid action %q: supported actions are Read, Write, List, Tagging, Admin", a)
|
||||
}
|
||||
if _, dup := seen[canonical]; dup {
|
||||
continue
|
||||
}
|
||||
seen[canonical] = struct{}{}
|
||||
sa.Actions = append(sa.Actions, canonical)
|
||||
}
|
||||
parsed, err := canonicalActionList(*actions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sa.Actions = parsed
|
||||
}
|
||||
|
||||
if *expiry < 0 {
|
||||
|
||||
Reference in new issue
Block a user