From 03353a0aec65761ced4923168bed3ef3b1178ff3 Mon Sep 17 00:00:00 2001 From: kqjy Date: Sun, 8 Mar 2026 13:08:57 +0800 Subject: [PATCH 1/3] Add credential expiry support: per-user expires_at with UI management, presets, and badge indicators; Add credential expiry support: per-user expires_at with UI management, presets, and badge indicators; Fix IAM card dropdown clipped by overflow: remove gradient bar, allow overflow visible --- app/__init__.py | 1 + app/iam.py | 168 ++++++++++++++++++++++++++++++------ app/ui.py | 62 ++++++++++++- app/version.py | 2 +- run.py | 103 +++++++++++++++++++++- static/css/main.css | 21 +---- static/js/iam-management.js | 137 ++++++++++++++++++++++++++++- templates/iam.html | 114 +++++++++++++++++++++++- 8 files changed, 553 insertions(+), 55 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 1da677e..89dbdbd 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -130,6 +130,7 @@ def create_app( Path(app.config["IAM_CONFIG"]), auth_max_attempts=app.config.get("AUTH_MAX_ATTEMPTS", 5), auth_lockout_minutes=app.config.get("AUTH_LOCKOUT_MINUTES", 15), + encryption_key=app.config.get("SECRET_KEY"), ) bucket_policies = BucketPolicyStore(Path(app.config["BUCKET_POLICY_PATH"])) secret_store = EphemeralSecretStore(default_ttl=app.config.get("SECRET_TTL_SECONDS", 300)) diff --git a/app/iam.py b/app/iam.py index 074f6c1..61aae45 100644 --- a/app/iam.py +++ b/app/iam.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import hashlib import hmac import json @@ -14,6 +15,8 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, Deque, Dict, Iterable, List, Optional, Sequence, Set, Tuple +from cryptography.fernet import Fernet, InvalidToken + class IamError(RuntimeError): """Raised when authentication or authorization fails.""" @@ -107,13 +110,24 @@ class Principal: policies: List[Policy] +def _derive_fernet_key(secret: str) -> bytes: + raw = hashlib.pbkdf2_hmac("sha256", secret.encode(), b"myfsio-iam-encryption", 100_000) + return base64.urlsafe_b64encode(raw) + + +_IAM_ENCRYPTED_PREFIX = b"MYFSIO_IAM_ENC:" + + class IamService: """Loads IAM configuration, manages users, and evaluates policies.""" - def __init__(self, config_path: Path, auth_max_attempts: int = 5, auth_lockout_minutes: int = 15) -> None: + def __init__(self, config_path: Path, auth_max_attempts: int = 5, auth_lockout_minutes: int = 15, encryption_key: str | None = None) -> None: self.config_path = Path(config_path) self.auth_max_attempts = auth_max_attempts self.auth_lockout_window = timedelta(minutes=auth_lockout_minutes) + self._fernet: Fernet | None = None + if encryption_key: + self._fernet = Fernet(_derive_fernet_key(encryption_key)) self.config_path.parent.mkdir(parents=True, exist_ok=True) if not self.config_path.exists(): self._write_default() @@ -145,6 +159,19 @@ class IamService: except OSError: pass + def _check_expiry(self, access_key: str, record: Dict[str, Any]) -> None: + expires_at = record.get("expires_at") + if not expires_at: + return + try: + exp_dt = datetime.fromisoformat(expires_at) + if exp_dt.tzinfo is None: + exp_dt = exp_dt.replace(tzinfo=timezone.utc) + if datetime.now(timezone.utc) >= exp_dt: + raise IamError(f"Credentials for '{access_key}' have expired") + except (ValueError, TypeError): + pass + def authenticate(self, access_key: str, secret_key: str) -> Principal: self._maybe_reload() access_key = (access_key or "").strip() @@ -161,6 +188,7 @@ class IamService: if not record or not hmac.compare_digest(stored_secret, secret_key): self._record_failed_attempt(access_key) raise IamError("Invalid credentials") + self._check_expiry(access_key, record) self._clear_failed_attempts(access_key) return self._build_principal(access_key, record) @@ -288,12 +316,16 @@ class IamService: if cached: principal, cached_time = cached if now - cached_time < self._cache_ttl: + record = self._users.get(access_key) + if record: + self._check_expiry(access_key, record) return principal self._maybe_reload() record = self._users.get(access_key) if not record: raise IamError("Unknown access key") + self._check_expiry(access_key, record) principal = self._build_principal(access_key, record) self._principal_cache[access_key] = (principal, now) return principal @@ -303,6 +335,7 @@ class IamService: record = self._users.get(access_key) if not record: raise IamError("Unknown access key") + self._check_expiry(access_key, record) return record["secret_key"] def authorize(self, principal: Principal, bucket_name: str | None, action: str) -> None: @@ -347,6 +380,7 @@ class IamService: { "access_key": access_key, "display_name": record["display_name"], + "expires_at": record.get("expires_at"), "policies": [ {"bucket": policy.bucket, "actions": sorted(policy.actions)} for policy in record["policies"] @@ -362,20 +396,25 @@ class IamService: policies: Optional[Sequence[Dict[str, Any]]] = None, access_key: str | None = None, secret_key: str | None = None, + expires_at: str | None = None, ) -> Dict[str, str]: access_key = (access_key or self._generate_access_key()).strip() if not access_key: raise IamError("Access key cannot be empty") if access_key in self._users: raise IamError("Access key already exists") + if expires_at: + self._validate_expires_at(expires_at) secret_key = secret_key or self._generate_secret_key() sanitized_policies = self._prepare_policy_payload(policies) - record = { + record: Dict[str, Any] = { "access_key": access_key, "secret_key": secret_key, "display_name": display_name or access_key, "policies": sanitized_policies, } + if expires_at: + record["expires_at"] = expires_at self._raw_config.setdefault("users", []).append(record) self._save() self._load() @@ -414,17 +453,43 @@ class IamService: clear_signing_key_cache() self._load() + def update_user_expiry(self, access_key: str, expires_at: str | None) -> None: + user = self._get_raw_user(access_key) + if expires_at: + self._validate_expires_at(expires_at) + user["expires_at"] = expires_at + else: + user.pop("expires_at", None) + self._save() + self._principal_cache.pop(access_key, None) + self._secret_key_cache.pop(access_key, None) + self._load() + def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None: user = self._get_raw_user(access_key) user["policies"] = self._prepare_policy_payload(policies) self._save() self._load() + def _decrypt_content(self, raw_bytes: bytes) -> str: + if raw_bytes.startswith(_IAM_ENCRYPTED_PREFIX): + if not self._fernet: + raise IamError("IAM config is encrypted but no encryption key provided. Set SECRET_KEY or use 'python run.py reset-cred'.") + try: + encrypted_data = raw_bytes[len(_IAM_ENCRYPTED_PREFIX):] + return self._fernet.decrypt(encrypted_data).decode("utf-8") + except InvalidToken: + raise IamError("Cannot decrypt IAM config. SECRET_KEY may have changed. Use 'python run.py reset-cred' to reset credentials.") + return raw_bytes.decode("utf-8") + def _load(self) -> None: try: self._last_load_time = self.config_path.stat().st_mtime - content = self.config_path.read_text(encoding='utf-8') + raw_bytes = self.config_path.read_bytes() + content = self._decrypt_content(raw_bytes) raw = json.loads(content) + except IamError: + raise except FileNotFoundError: raise IamError(f"IAM config not found: {self.config_path}") except json.JSONDecodeError as e: @@ -433,34 +498,48 @@ class IamService: raise IamError(f"Cannot read IAM config (permission denied): {e}") except (OSError, ValueError) as e: raise IamError(f"Failed to load IAM config: {e}") - + + was_plaintext = not raw_bytes.startswith(_IAM_ENCRYPTED_PREFIX) + users: Dict[str, Dict[str, Any]] = {} for user in raw.get("users", []): policies = self._build_policy_objects(user.get("policies", [])) - users[user["access_key"]] = { + user_record: Dict[str, Any] = { "secret_key": user["secret_key"], "display_name": user.get("display_name", user["access_key"]), "policies": policies, } + if user.get("expires_at"): + user_record["expires_at"] = user["expires_at"] + users[user["access_key"]] = user_record if not users: raise IamError("IAM configuration contains no users") self._users = users - self._raw_config = { - "users": [ - { - "access_key": entry["access_key"], - "secret_key": entry["secret_key"], - "display_name": entry.get("display_name", entry["access_key"]), - "policies": entry.get("policies", []), - } - for entry in raw.get("users", []) - ] - } + raw_users: List[Dict[str, Any]] = [] + for entry in raw.get("users", []): + raw_entry: Dict[str, Any] = { + "access_key": entry["access_key"], + "secret_key": entry["secret_key"], + "display_name": entry.get("display_name", entry["access_key"]), + "policies": entry.get("policies", []), + } + if entry.get("expires_at"): + raw_entry["expires_at"] = entry["expires_at"] + raw_users.append(raw_entry) + self._raw_config = {"users": raw_users} + + if was_plaintext and self._fernet: + self._save() def _save(self) -> None: try: + json_text = json.dumps(self._raw_config, indent=2) temp_path = self.config_path.with_suffix('.json.tmp') - temp_path.write_text(json.dumps(self._raw_config, indent=2), encoding='utf-8') + if self._fernet: + encrypted = self._fernet.encrypt(json_text.encode("utf-8")) + temp_path.write_bytes(_IAM_ENCRYPTED_PREFIX + encrypted) + else: + temp_path.write_text(json_text, encoding='utf-8') temp_path.replace(self.config_path) except (OSError, PermissionError) as e: raise IamError(f"Cannot save IAM config: {e}") @@ -475,9 +554,14 @@ class IamService: def export_config(self, mask_secrets: bool = True) -> Dict[str, Any]: payload: Dict[str, Any] = {"users": []} for user in self._raw_config.get("users", []): - record = dict(user) - if mask_secrets and "secret_key" in record: - record["secret_key"] = "••••••••••" + record: Dict[str, Any] = { + "access_key": user["access_key"], + "secret_key": "••••••••••" if mask_secrets else user["secret_key"], + "display_name": user["display_name"], + "policies": user["policies"], + } + if user.get("expires_at"): + record["expires_at"] = user["expires_at"] payload["users"].append(record) return payload @@ -546,8 +630,9 @@ class IamService: return candidate if candidate in ALLOWED_ACTIONS else "" def _write_default(self) -> None: - access_key = secrets.token_hex(12) - secret_key = secrets.token_urlsafe(32) + access_key = os.environ.get("ADMIN_ACCESS_KEY", "").strip() or secrets.token_hex(12) + secret_key = os.environ.get("ADMIN_SECRET_KEY", "").strip() or secrets.token_urlsafe(32) + custom_keys = bool(os.environ.get("ADMIN_ACCESS_KEY", "").strip()) default = { "users": [ { @@ -560,16 +645,37 @@ class IamService: } ] } - self.config_path.write_text(json.dumps(default, indent=2)) + json_text = json.dumps(default, indent=2) + if self._fernet: + encrypted = self._fernet.encrypt(json_text.encode("utf-8")) + self.config_path.write_bytes(_IAM_ENCRYPTED_PREFIX + encrypted) + else: + self.config_path.write_text(json_text) print(f"\n{'='*60}") - print("MYFSIO FIRST RUN - ADMIN CREDENTIALS GENERATED") + print("MYFSIO FIRST RUN - ADMIN CREDENTIALS") print(f"{'='*60}") - print(f"Access Key: {access_key}") - print(f"Secret Key: {secret_key}") + if custom_keys: + print(f"Access Key: {access_key} (from ADMIN_ACCESS_KEY)") + print(f"Secret Key: {'(from ADMIN_SECRET_KEY)' if os.environ.get('ADMIN_SECRET_KEY', '').strip() else secret_key}") + else: + print(f"Access Key: {access_key}") + print(f"Secret Key: {secret_key}") print(f"{'='*60}") - print(f"Missed this? Check: {self.config_path}") + if self._fernet: + print("IAM config is encrypted at rest.") + print("Lost credentials? Run: python run.py reset-cred") + else: + print(f"Missed this? Check: {self.config_path}") print(f"{'='*60}\n") + def _validate_expires_at(self, expires_at: str) -> None: + try: + dt = datetime.fromisoformat(expires_at) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + raise IamError(f"Invalid expires_at format: {expires_at}. Use ISO 8601 (e.g. 2026-12-31T23:59:59Z)") + def _generate_access_key(self) -> str: return secrets.token_hex(8) @@ -588,11 +694,15 @@ class IamService: if cached: secret_key, cached_time = cached if now - cached_time < self._cache_ttl: + record = self._users.get(access_key) + if record: + self._check_expiry(access_key, record) return secret_key self._maybe_reload() record = self._users.get(access_key) if record: + self._check_expiry(access_key, record) secret_key = record["secret_key"] self._secret_key_cache[access_key] = (secret_key, now) return secret_key @@ -604,11 +714,15 @@ class IamService: if cached: principal, cached_time = cached if now - cached_time < self._cache_ttl: + record = self._users.get(access_key) + if record: + self._check_expiry(access_key, record) return principal self._maybe_reload() record = self._users.get(access_key) if record: + self._check_expiry(access_key, record) principal = self._build_principal(access_key, record) self._principal_cache[access_key] = (principal, now) return principal diff --git a/app/ui.py b/app/ui.py index 908a0c7..8628616 100644 --- a/app/ui.py +++ b/app/ui.py @@ -1754,6 +1754,10 @@ def iam_dashboard(): users = iam_service.list_users() if not locked else [] config_summary = iam_service.config_summary() config_document = json.dumps(iam_service.export_config(mask_secrets=True), indent=2) + from datetime import datetime as _dt, timedelta as _td, timezone as _tz + _now = _dt.now(_tz.utc) + now_iso = _now.isoformat() + soon_iso = (_now + _td(days=7)).isoformat() return render_template( "iam.html", users=users, @@ -1763,6 +1767,8 @@ def iam_dashboard(): config_summary=config_summary, config_document=config_document, disclosed_secret=disclosed_secret, + now_iso=now_iso, + soon_iso=soon_iso, ) @@ -1782,6 +1788,8 @@ def create_iam_user(): return jsonify({"error": "Display name must be 64 characters or fewer"}), 400 flash("Display name must be 64 characters or fewer", "danger") return redirect(url_for("ui.iam_dashboard")) + custom_access_key = request.form.get("access_key", "").strip() or None + custom_secret_key = request.form.get("secret_key", "").strip() or None policies_text = request.form.get("policies", "").strip() policies = None if policies_text: @@ -1792,8 +1800,21 @@ def create_iam_user(): return jsonify({"error": f"Invalid JSON: {exc}"}), 400 flash(f"Invalid JSON: {exc}", "danger") return redirect(url_for("ui.iam_dashboard")) + expires_at = request.form.get("expires_at", "").strip() or None + if expires_at: + try: + from datetime import datetime as _dt, timezone as _tz + exp_dt = _dt.fromisoformat(expires_at) + if exp_dt.tzinfo is None: + exp_dt = exp_dt.replace(tzinfo=_tz.utc) + expires_at = exp_dt.isoformat() + except (ValueError, TypeError): + if _wants_json(): + return jsonify({"error": "Invalid expiry date format"}), 400 + flash("Invalid expiry date format", "danger") + return redirect(url_for("ui.iam_dashboard")) try: - created = _iam().create_user(display_name=display_name, policies=policies) + created = _iam().create_user(display_name=display_name, policies=policies, access_key=custom_access_key, secret_key=custom_secret_key, expires_at=expires_at) except IamError as exc: if _wants_json(): return jsonify({"error": str(exc)}), 400 @@ -1967,6 +1988,45 @@ def update_iam_policies(access_key: str): return redirect(url_for("ui.iam_dashboard")) +@ui_bp.post("/iam/users//expiry") +def update_iam_expiry(access_key: str): + principal = _current_principal() + try: + _iam().authorize(principal, None, "iam:update_policy") + except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 403 + flash(str(exc), "danger") + return redirect(url_for("ui.iam_dashboard")) + + expires_at = request.form.get("expires_at", "").strip() or None + if expires_at: + try: + from datetime import datetime as _dt, timezone as _tz + exp_dt = _dt.fromisoformat(expires_at) + if exp_dt.tzinfo is None: + exp_dt = exp_dt.replace(tzinfo=_tz.utc) + expires_at = exp_dt.isoformat() + except (ValueError, TypeError): + if _wants_json(): + return jsonify({"error": "Invalid expiry date format"}), 400 + flash("Invalid expiry date format", "danger") + return redirect(url_for("ui.iam_dashboard")) + + try: + _iam().update_user_expiry(access_key, expires_at) + if _wants_json(): + return jsonify({"success": True, "message": f"Updated expiry for {access_key}", "expires_at": expires_at}) + label = expires_at if expires_at else "never" + flash(f"Expiry for {access_key} set to {label}", "success") + except IamError as exc: + if _wants_json(): + return jsonify({"error": str(exc)}), 400 + flash(str(exc), "danger") + + return redirect(url_for("ui.iam_dashboard")) + + @ui_bp.post("/connections") def create_connection(): principal = _current_principal() diff --git a/app/version.py b/app/version.py index d900925..e049a33 100644 --- a/app/version.py +++ b/app/version.py @@ -1,6 +1,6 @@ from __future__ import annotations -APP_VERSION = "0.3.6" +APP_VERSION = "0.3.7" def get_version() -> str: diff --git a/run.py b/run.py index ae120fe..fbe110f 100644 --- a/run.py +++ b/run.py @@ -23,6 +23,7 @@ from typing import Optional from app import create_api_app, create_ui_app from app.config import AppConfig +from app.iam import IamService, IamError, ALLOWED_ACTIONS, _derive_fernet_key def _server_host() -> str: @@ -87,21 +88,121 @@ def serve_ui(port: int, prod: bool = False, config: Optional[AppConfig] = None) app.run(host=_server_host(), port=port, debug=debug) +def reset_credentials() -> None: + import json + import secrets + from cryptography.fernet import Fernet + + config = AppConfig.from_env() + iam_path = config.iam_config_path + encryption_key = config.secret_key + + access_key = os.environ.get("ADMIN_ACCESS_KEY", "").strip() or secrets.token_hex(12) + secret_key = os.environ.get("ADMIN_SECRET_KEY", "").strip() or secrets.token_urlsafe(32) + custom_keys = bool(os.environ.get("ADMIN_ACCESS_KEY", "").strip()) + + fernet = Fernet(_derive_fernet_key(encryption_key)) if encryption_key else None + + raw_config = None + if iam_path.exists(): + try: + raw_bytes = iam_path.read_bytes() + from app.iam import _IAM_ENCRYPTED_PREFIX + if raw_bytes.startswith(_IAM_ENCRYPTED_PREFIX): + if fernet: + try: + content = fernet.decrypt(raw_bytes[len(_IAM_ENCRYPTED_PREFIX):]).decode("utf-8") + raw_config = json.loads(content) + except Exception: + print("WARNING: Could not decrypt existing IAM config. Creating fresh config.") + else: + print("WARNING: IAM config is encrypted but no SECRET_KEY available. Creating fresh config.") + else: + try: + raw_config = json.loads(raw_bytes.decode("utf-8")) + except json.JSONDecodeError: + print("WARNING: Existing IAM config is corrupted. Creating fresh config.") + except OSError: + pass + + if raw_config and raw_config.get("users"): + admin_user = None + for user in raw_config["users"]: + policies = user.get("policies", []) + for p in policies: + actions = p.get("actions", []) + if "iam:*" in actions or "*" in actions: + admin_user = user + break + if admin_user: + break + if not admin_user: + admin_user = raw_config["users"][0] + + admin_user["access_key"] = access_key + admin_user["secret_key"] = secret_key + else: + raw_config = { + "users": [ + { + "access_key": access_key, + "secret_key": secret_key, + "display_name": "Local Admin", + "policies": [ + {"bucket": "*", "actions": list(ALLOWED_ACTIONS)} + ], + } + ] + } + + json_text = json.dumps(raw_config, indent=2) + iam_path.parent.mkdir(parents=True, exist_ok=True) + temp_path = iam_path.with_suffix(".json.tmp") + if fernet: + from app.iam import _IAM_ENCRYPTED_PREFIX + encrypted = fernet.encrypt(json_text.encode("utf-8")) + temp_path.write_bytes(_IAM_ENCRYPTED_PREFIX + encrypted) + else: + temp_path.write_text(json_text, encoding="utf-8") + temp_path.replace(iam_path) + + print(f"\n{'='*60}") + print("MYFSIO - ADMIN CREDENTIALS RESET") + print(f"{'='*60}") + if custom_keys: + print(f"Access Key: {access_key} (from ADMIN_ACCESS_KEY)") + print(f"Secret Key: {'(from ADMIN_SECRET_KEY)' if os.environ.get('ADMIN_SECRET_KEY', '').strip() else secret_key}") + else: + print(f"Access Key: {access_key}") + print(f"Secret Key: {secret_key}") + print(f"{'='*60}") + if fernet: + print("IAM config saved (encrypted).") + else: + print(f"IAM config saved to: {iam_path}") + print(f"{'='*60}\n") + + if __name__ == "__main__": multiprocessing.freeze_support() if _is_frozen(): multiprocessing.set_start_method("spawn", force=True) parser = argparse.ArgumentParser(description="Run the S3 clone services.") - parser.add_argument("--mode", choices=["api", "ui", "both"], default="both") + parser.add_argument("--mode", choices=["api", "ui", "both", "reset-cred"], default="both") parser.add_argument("--api-port", type=int, default=5000) parser.add_argument("--ui-port", type=int, default=5100) parser.add_argument("--prod", action="store_true", help="Run in production mode using Waitress") parser.add_argument("--dev", action="store_true", help="Force development mode (Flask dev server)") parser.add_argument("--check-config", action="store_true", help="Validate configuration and exit") parser.add_argument("--show-config", action="store_true", help="Show configuration summary and exit") + parser.add_argument("--reset-cred", action="store_true", help="Reset admin credentials and exit") args = parser.parse_args() + if args.reset_cred or args.mode == "reset-cred": + reset_credentials() + sys.exit(0) + if args.check_config or args.show_config: config = AppConfig.from_env() config.print_startup_summary() diff --git a/static/css/main.css b/static/css/main.css index 9b38cd3..89aaab9 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1154,39 +1154,20 @@ html.sidebar-will-collapse .sidebar-user { position: relative; border: 1px solid var(--myfsio-card-border) !important; border-radius: 1rem !important; - overflow: hidden; + overflow: visible; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); } -.iam-user-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - background: linear-gradient(90deg, #3b82f6, #8b5cf6); - opacity: 0; - transition: opacity 0.2s ease; -} - .iam-user-card:hover { transform: translateY(-2px); box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.12), 0 4px 8px -4px rgba(0, 0, 0, 0.08); border-color: var(--myfsio-accent) !important; } -.iam-user-card:hover::before { - opacity: 1; -} - [data-theme='dark'] .iam-user-card:hover { box-shadow: 0 8px 24px -4px rgba(0, 0, 0, 0.4), 0 4px 8px -4px rgba(0, 0, 0, 0.3); } -.iam-admin-card::before { - background: linear-gradient(90deg, #f59e0b, #ef4444); -} .iam-role-badge { display: inline-flex; diff --git a/static/js/iam-management.js b/static/js/iam-management.js index 56710f7..e75484b 100644 --- a/static/js/iam-management.js +++ b/static/js/iam-management.js @@ -11,9 +11,11 @@ window.IAMManagement = (function() { var editUserModal = null; var deleteUserModal = null; var rotateSecretModal = null; + var expiryModal = null; var currentRotateKey = null; var currentEditKey = null; var currentDeleteKey = null; + var currentExpiryKey = null; var ALL_S3_ACTIONS = ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors']; @@ -65,6 +67,7 @@ window.IAMManagement = (function() { setupEditUserModal(); setupDeleteUserModal(); setupRotateSecretModal(); + setupExpiryModal(); setupFormHandlers(); setupSearch(); setupCopyAccessKeyButtons(); @@ -75,11 +78,13 @@ window.IAMManagement = (function() { var editModalEl = document.getElementById('editUserModal'); var deleteModalEl = document.getElementById('deleteUserModal'); var rotateModalEl = document.getElementById('rotateSecretModal'); + var expiryModalEl = document.getElementById('expiryModal'); if (policyModalEl) policyModal = new bootstrap.Modal(policyModalEl); if (editModalEl) editUserModal = new bootstrap.Modal(editModalEl); if (deleteModalEl) deleteUserModal = new bootstrap.Modal(deleteModalEl); if (rotateModalEl) rotateSecretModal = new bootstrap.Modal(rotateModalEl); + if (expiryModalEl) expiryModal = new bootstrap.Modal(expiryModalEl); } function setupJsonAutoIndent() { @@ -97,6 +102,15 @@ window.IAMManagement = (function() { }); }); + var accessKeyCopyButton = document.querySelector('[data-access-key-copy]'); + if (accessKeyCopyButton) { + accessKeyCopyButton.addEventListener('click', async function() { + var accessKeyInput = document.getElementById('disclosedAccessKeyValue'); + if (!accessKeyInput) return; + await window.UICore.copyToClipboard(accessKeyInput.value, accessKeyCopyButton, 'Copy'); + }); + } + var secretCopyButton = document.querySelector('[data-secret-copy]'); if (secretCopyButton) { secretCopyButton.addEventListener('click', async function() { @@ -143,6 +157,22 @@ window.IAMManagement = (function() { }); } + function generateSecureHex(byteCount) { + var arr = new Uint8Array(byteCount); + crypto.getRandomValues(arr); + return Array.from(arr).map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); + } + + function generateSecureBase64(byteCount) { + var arr = new Uint8Array(byteCount); + crypto.getRandomValues(arr); + var binary = ''; + for (var i = 0; i < arr.length; i++) { + binary += String.fromCharCode(arr[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + function setupCreateUserModal() { var createUserPoliciesEl = document.getElementById('createUserPolicies'); @@ -151,6 +181,22 @@ window.IAMManagement = (function() { applyPolicyTemplate(button.dataset.createPolicyTemplate, createUserPoliciesEl); }); }); + + var genAccessKeyBtn = document.getElementById('generateAccessKeyBtn'); + if (genAccessKeyBtn) { + genAccessKeyBtn.addEventListener('click', function() { + var input = document.getElementById('createUserAccessKey'); + if (input) input.value = generateSecureHex(8); + }); + } + + var genSecretKeyBtn = document.getElementById('generateSecretKeyBtn'); + if (genSecretKeyBtn) { + genSecretKeyBtn.addEventListener('click', function() { + var input = document.getElementById('createUserSecretKey'); + if (input) input.value = generateSecureBase64(24); + }); + } } function setupEditUserModal() { @@ -271,6 +317,77 @@ window.IAMManagement = (function() { } } + function openExpiryModal(key, expiresAt) { + currentExpiryKey = key; + var label = document.getElementById('expiryUserLabel'); + var input = document.getElementById('expiryDateInput'); + var form = document.getElementById('expiryForm'); + if (label) label.textContent = key; + if (expiresAt) { + try { + var dt = new Date(expiresAt); + var local = new Date(dt.getTime() - dt.getTimezoneOffset() * 60000); + if (input) input.value = local.toISOString().slice(0, 16); + } catch(e) { + if (input) input.value = ''; + } + } else { + if (input) input.value = ''; + } + if (form) form.action = endpoints.updateExpiry.replace('ACCESS_KEY', key); + var modalEl = document.getElementById('expiryModal'); + if (modalEl) { + var modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + } + } + + function setupExpiryModal() { + document.querySelectorAll('[data-expiry-user]').forEach(function(btn) { + btn.addEventListener('click', function(e) { + e.preventDefault(); + openExpiryModal(btn.dataset.expiryUser, btn.dataset.expiresAt || ''); + }); + }); + + document.querySelectorAll('[data-expiry-preset]').forEach(function(btn) { + btn.addEventListener('click', function() { + var preset = btn.dataset.expiryPreset; + var input = document.getElementById('expiryDateInput'); + if (!input) return; + if (preset === 'clear') { + input.value = ''; + return; + } + var now = new Date(); + var ms = 0; + if (preset === '1h') ms = 3600000; + else if (preset === '24h') ms = 86400000; + else if (preset === '7d') ms = 7 * 86400000; + else if (preset === '30d') ms = 30 * 86400000; + else if (preset === '90d') ms = 90 * 86400000; + var future = new Date(now.getTime() + ms); + var local = new Date(future.getTime() - future.getTimezoneOffset() * 60000); + input.value = local.toISOString().slice(0, 16); + }); + }); + + var expiryForm = document.getElementById('expiryForm'); + if (expiryForm) { + expiryForm.addEventListener('submit', function(e) { + e.preventDefault(); + window.UICore.submitFormAjax(expiryForm, { + successMessage: 'Expiry updated', + onSuccess: function() { + var modalEl = document.getElementById('expiryModal'); + if (modalEl) bootstrap.Modal.getOrCreateInstance(modalEl).hide(); + window.location.reload(); + } + }); + }); + } + } + function createUserCardHtml(accessKey, displayName, policies) { var admin = isAdminUser(policies); var cardClass = 'card h-100 iam-user-card' + (admin ? ' iam-admin-card' : ''); @@ -324,6 +441,8 @@ window.IAMManagement = (function() { '