Fix peer-site edit 422; align IAM admin definition across runtime/UI/JS; auto-migrate legacy full-access policies (gated on iam:* to avoid promoting bucketadmin); reject empty endpoint on peer-site update; update docs

This commit is contained in:
2026-04-26 23:02:38 +08:00
parent 0a60ea4348
commit 6c5ccee8cb
6 changed files with 127 additions and 18 deletions

View File

@@ -77,17 +77,66 @@ impl RawIamUser {
let user_id = self.user_id.unwrap_or_else(|| {
format!("u-{}", display_name.to_ascii_lowercase().replace(' ', "-"))
});
let policies = self
.policies
.into_iter()
.map(normalize_legacy_full_access)
.collect();
IamUser {
user_id,
display_name,
enabled: self.enabled,
expires_at: self.expires_at,
access_keys,
policies: self.policies,
policies,
}
}
}
const LEGACY_FULL_ACCESS_ACTIONS: &[&str] = &[
"list",
"read",
"write",
"delete",
"share",
"policy",
"create_bucket",
"delete_bucket",
"replication",
"lifecycle",
"cors",
"versioning",
"tagging",
"encryption",
"quota",
"object_lock",
"notification",
"logging",
"website",
];
fn normalize_legacy_full_access(policy: IamPolicy) -> IamPolicy {
if policy.bucket != "*"
|| policy.prefix != "*"
|| policy.actions.iter().any(|a| a == "*")
{
return policy;
}
if !policy.actions.iter().any(|a| a == "iam:*") {
return policy;
}
for required in LEGACY_FULL_ACCESS_ACTIONS {
if !policy.actions.iter().any(|a| a == *required) {
return policy;
}
}
IamPolicy {
bucket: policy.bucket,
prefix: policy.prefix,
actions: vec!["*".to_string()],
}
}
fn default_enabled() -> bool {
true
}

View File

@@ -721,13 +721,21 @@ pub async fn iam_dashboard(
.as_array()
.map(|items| {
items.iter().any(|policy| {
let bucket_wildcard = policy
.get("bucket")
.and_then(|v| v.as_str())
.map(|b| b == "*")
.unwrap_or(false);
if !bucket_wildcard {
return false;
}
policy
.get("actions")
.and_then(|value| value.as_array())
.map(|actions| {
actions
.iter()
.any(|action| matches!(action.as_str(), Some("*") | Some("iam:*")))
.any(|action| action.as_str() == Some("*"))
})
.unwrap_or(false)
})
@@ -1744,6 +1752,20 @@ pub async fn update_peer_site(
return Redirect::to("/ui/sites").into_response();
};
let endpoint = form.endpoint.trim().to_string();
if endpoint.is_empty() {
let message = "Endpoint is required.".to_string();
if wants_json {
return (
StatusCode::BAD_REQUEST,
axum::Json(json!({ "error": message })),
)
.into_response();
}
session.write(|s| s.push_flash("danger", message));
return Redirect::to("/ui/sites").into_response();
}
let connection_id = {
let value = form.connection_id.trim();
if value.is_empty() {
@@ -1777,7 +1799,7 @@ pub async fn update_peer_site(
};
let peer = crate::services::site_registry::PeerSite {
site_id: site_id.clone(),
endpoint: form.endpoint.trim().to_string(),
endpoint,
region: form.region.trim().to_string(),
priority: form.priority,
display_name: {

View File

@@ -3,6 +3,53 @@ use std::time::Duration;
use chrono::Utc;
use serde_json::Value;
fn extract_error_detail(body: &str) -> String {
let trimmed = body.trim();
if trimmed.is_empty() {
return String::new();
}
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
let err = value.get("error").unwrap_or(&value);
let code = err
.get("code")
.or_else(|| err.get("Code"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let message = err
.get("message")
.or_else(|| err.get("Message"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
let detail = match (code, message) {
(Some(c), Some(m)) => format!("{}: {}", c, m),
(Some(c), None) => c.to_string(),
(None, Some(m)) => m.to_string(),
(None, None) => String::new(),
};
if !detail.is_empty() {
return truncate_chars(&detail, 240);
}
}
let collapsed = trimmed
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
truncate_chars(&collapsed, 240)
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
match s.char_indices().nth(max_chars) {
Some((boundary, _)) => format!("{}", &s[..boundary]),
None => s.to_string(),
}
}
use myfsio_auth::sigv4::{
aws_uri_encode, build_string_to_sign, compute_signature, derive_signing_key, sha256_hex,
};
@@ -117,16 +164,7 @@ impl PeerAdminClient {
let status = resp.status();
if !status.is_success() {
let body_text = resp.text().await.unwrap_or_default();
let detail = body_text
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
let detail = match detail.char_indices().nth(240) {
Some((boundary, _)) => format!("{}", &detail[..boundary]),
None => detail,
};
let detail = extract_error_detail(&body_text);
if detail.is_empty() {
return Err(format!("peer returned status {}", status.as_u16()));
}

View File

@@ -29,7 +29,7 @@ window.IAMManagement = (function() {
];
var policyTemplates = {
full: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'create_bucket', 'delete_bucket', 'replication', 'lifecycle', 'cors', 'versioning', 'tagging', 'encryption', 'quota', 'object_lock', 'notification', 'logging', 'website', 'iam:*'] }],
full: [{ bucket: '*', actions: ['*'] }],
readonly: [{ bucket: '*', actions: ['list', 'read'] }],
writer: [{ bucket: '*', actions: ['list', 'read', 'write'] }],
operator: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'create_bucket', 'delete_bucket'] }],
@@ -39,7 +39,7 @@ window.IAMManagement = (function() {
function isAdminUser(policies) {
if (!policies || !policies.length) return false;
return policies.some(function(p) {
return p.actions && (p.actions.indexOf('iam:*') >= 0 || p.actions.indexOf('*') >= 0);
return p.bucket === '*' && p.actions && p.actions.indexOf('*') >= 0;
});
}

View File

@@ -529,7 +529,7 @@ sudo journalctl -u myfsio -f # View logs</code></pre>
<div class="docs-highlight mb-3">
<ol class="mb-0">
<li>Check the console output for the generated <code>Access Key</code> and <code>Secret Key</code>, then visit <code>/ui/login</code>.</li>
<li>Create additional users with descriptive display names, AWS-style inline policies (for example <code>{"bucket": "*", "actions": ["list", "read"]}</code>), and optional credential expiry dates.</li>
<li>Create additional users with descriptive display names, AWS-style inline policies (for example <code>{"bucket": "*", "actions": ["list", "read"]}</code>), and optional credential expiry dates. Use <code>{"bucket": "*", "actions": ["*"]}</code> to grant full administrator access — this is the only policy shape that satisfies <code>require_admin</code> on routes such as <code>/admin/cluster/overview</code>. <code>iam:*</code> grants only IAM-management actions and is <strong>not</strong> a substitute for <code>"*"</code> on admin routes.</li>
<li>Set credential expiry on users to grant time-limited access. The UI shows expiry badges and provides preset durations (1h, 24h, 7d, 30d, 90d). Expired credentials are rejected at authentication.</li>
<li>Rotate secrets when sharing with CI jobs—new secrets display once and persist to <code>data/.myfsio.sys/config/iam.json</code>.</li>
<li>Bucket policies layer on top of IAM. Apply Private/Public presets or paste custom JSON; changes reload instantly.</li>
@@ -1351,7 +1351,7 @@ curl "{{ api_base }}/&lt;bucket&gt;/&lt;key&gt;?versionId=&lt;version-id&gt;" \
</div>
<h3 class="h6 text-uppercase text-muted mt-4">Managing Quotas (Admin Only)</h3>
<p class="small text-muted">Quota management is restricted to administrators (users with <code>iam:*</code> permissions).</p>
<p class="small text-muted">Quota management is restricted to administrators users whose policy is <code>{"bucket": "*", "actions": ["*"]}</code>.</p>
<ol class="docs-steps mb-3">
<li>Navigate to your bucket → <strong>Properties</strong> tab → <strong>Storage Quota</strong> card.</li>
<li>Enter limits: <strong>Max Size (MB)</strong> and/or <strong>Max Objects</strong>. Leave empty for unlimited.</li>

View File

@@ -400,7 +400,7 @@
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-medium">Site ID</label>
<input type="text" class="form-control" id="edit_site_id" readonly>
<input type="text" class="form-control" id="edit_site_id" name="site_id" readonly>
</div>
<div class="mb-3">
<label for="edit_endpoint" class="form-label fw-medium">Endpoint URL</label>