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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}/<bucket>/<key>?versionId=<version-id>" \
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user