Overhaul IAM: granular actions, multi-key users, prefix-scoped policies
This commit is contained in:
101
app/admin_api.py
101
app/admin_api.py
@@ -686,6 +686,107 @@ def _storage():
|
|||||||
return current_app.extensions["object_storage"]
|
return current_app.extensions["object_storage"]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_iam_action(action: str):
|
||||||
|
principal, error = _require_principal()
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
try:
|
||||||
|
_iam().authorize(principal, None, action)
|
||||||
|
return principal, None
|
||||||
|
except IamError:
|
||||||
|
return None, _json_error("AccessDenied", f"Requires {action} permission", 403)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/iam/users", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def iam_list_users():
|
||||||
|
principal, error = _require_iam_action("iam:list_users")
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
return jsonify({"users": _iam().list_users()})
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/iam/users/<identifier>", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def iam_get_user(identifier):
|
||||||
|
principal, error = _require_iam_action("iam:get_user")
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
user_id = _iam().resolve_user_id(identifier)
|
||||||
|
return jsonify(_iam().get_user_by_id(user_id))
|
||||||
|
except IamError as exc:
|
||||||
|
return _json_error("NotFound", str(exc), 404)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/iam/users/<identifier>/policies", methods=["GET"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def iam_get_user_policies(identifier):
|
||||||
|
principal, error = _require_iam_action("iam:get_policy")
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
return jsonify({"policies": _iam().get_user_policies(identifier)})
|
||||||
|
except IamError as exc:
|
||||||
|
return _json_error("NotFound", str(exc), 404)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/iam/users/<identifier>/keys", methods=["POST"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def iam_create_access_key(identifier):
|
||||||
|
principal, error = _require_iam_action("iam:create_key")
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
result = _iam().create_access_key(identifier)
|
||||||
|
logger.info("Access key created for %s by %s", identifier, principal.access_key)
|
||||||
|
return jsonify(result), 201
|
||||||
|
except IamError as exc:
|
||||||
|
return _json_error("InvalidRequest", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/iam/users/<identifier>/keys/<access_key>", methods=["DELETE"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def iam_delete_access_key(identifier, access_key):
|
||||||
|
principal, error = _require_iam_action("iam:delete_key")
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
_iam().delete_access_key(access_key)
|
||||||
|
logger.info("Access key %s deleted by %s", access_key, principal.access_key)
|
||||||
|
return "", 204
|
||||||
|
except IamError as exc:
|
||||||
|
return _json_error("InvalidRequest", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/iam/users/<identifier>/disable", methods=["POST"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def iam_disable_user(identifier):
|
||||||
|
principal, error = _require_iam_action("iam:disable_user")
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
_iam().disable_user(identifier)
|
||||||
|
logger.info("User %s disabled by %s", identifier, principal.access_key)
|
||||||
|
return jsonify({"status": "disabled"})
|
||||||
|
except IamError as exc:
|
||||||
|
return _json_error("InvalidRequest", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api_bp.route("/iam/users/<identifier>/enable", methods=["POST"])
|
||||||
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
|
def iam_enable_user(identifier):
|
||||||
|
principal, error = _require_iam_action("iam:disable_user")
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
try:
|
||||||
|
_iam().enable_user(identifier)
|
||||||
|
logger.info("User %s enabled by %s", identifier, principal.access_key)
|
||||||
|
return jsonify({"status": "enabled"})
|
||||||
|
except IamError as exc:
|
||||||
|
return _json_error("InvalidRequest", str(exc), 400)
|
||||||
|
|
||||||
|
|
||||||
@admin_api_bp.route("/website-domains", methods=["GET"])
|
@admin_api_bp.route("/website-domains", methods=["GET"])
|
||||||
@limiter.limit(lambda: _get_admin_rate_limit())
|
@limiter.limit(lambda: _get_admin_rate_limit())
|
||||||
def list_website_domains():
|
def list_website_domains():
|
||||||
|
|||||||
585
app/iam.py
585
app/iam.py
@@ -10,7 +10,7 @@ import secrets
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Deque, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
from typing import Any, Deque, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
||||||
@@ -22,16 +22,37 @@ class IamError(RuntimeError):
|
|||||||
"""Raised when authentication or authorization fails."""
|
"""Raised when authentication or authorization fails."""
|
||||||
|
|
||||||
|
|
||||||
S3_ACTIONS = {"list", "read", "write", "delete", "share", "policy", "replication", "lifecycle", "cors"}
|
S3_ACTIONS = {
|
||||||
|
"list", "read", "write", "delete", "share", "policy",
|
||||||
|
"replication", "lifecycle", "cors",
|
||||||
|
"create_bucket", "delete_bucket",
|
||||||
|
"versioning", "tagging", "encryption", "quota",
|
||||||
|
"object_lock", "notification", "logging", "website",
|
||||||
|
}
|
||||||
IAM_ACTIONS = {
|
IAM_ACTIONS = {
|
||||||
"iam:list_users",
|
"iam:list_users",
|
||||||
"iam:create_user",
|
"iam:create_user",
|
||||||
"iam:delete_user",
|
"iam:delete_user",
|
||||||
"iam:rotate_key",
|
"iam:rotate_key",
|
||||||
"iam:update_policy",
|
"iam:update_policy",
|
||||||
|
"iam:create_key",
|
||||||
|
"iam:delete_key",
|
||||||
|
"iam:get_user",
|
||||||
|
"iam:get_policy",
|
||||||
|
"iam:disable_user",
|
||||||
}
|
}
|
||||||
ALLOWED_ACTIONS = (S3_ACTIONS | IAM_ACTIONS) | {"iam:*"}
|
ALLOWED_ACTIONS = (S3_ACTIONS | IAM_ACTIONS) | {"iam:*"}
|
||||||
|
|
||||||
|
_V1_IMPLIED_ACTIONS = {
|
||||||
|
"write": {"create_bucket"},
|
||||||
|
"delete": {"delete_bucket"},
|
||||||
|
"policy": {
|
||||||
|
"versioning", "tagging", "encryption", "quota",
|
||||||
|
"object_lock", "notification", "logging", "website",
|
||||||
|
"cors", "lifecycle", "replication", "share",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
ACTION_ALIASES = {
|
ACTION_ALIASES = {
|
||||||
"list": "list",
|
"list": "list",
|
||||||
"s3:listbucket": "list",
|
"s3:listbucket": "list",
|
||||||
@@ -45,14 +66,11 @@ ACTION_ALIASES = {
|
|||||||
"s3:getobjecttagging": "read",
|
"s3:getobjecttagging": "read",
|
||||||
"s3:getobjectversiontagging": "read",
|
"s3:getobjectversiontagging": "read",
|
||||||
"s3:getobjectacl": "read",
|
"s3:getobjectacl": "read",
|
||||||
"s3:getbucketversioning": "read",
|
|
||||||
"s3:headobject": "read",
|
"s3:headobject": "read",
|
||||||
"s3:headbucket": "read",
|
"s3:headbucket": "read",
|
||||||
"write": "write",
|
"write": "write",
|
||||||
"s3:putobject": "write",
|
"s3:putobject": "write",
|
||||||
"s3:createbucket": "write",
|
|
||||||
"s3:putobjecttagging": "write",
|
"s3:putobjecttagging": "write",
|
||||||
"s3:putbucketversioning": "write",
|
|
||||||
"s3:createmultipartupload": "write",
|
"s3:createmultipartupload": "write",
|
||||||
"s3:uploadpart": "write",
|
"s3:uploadpart": "write",
|
||||||
"s3:completemultipartupload": "write",
|
"s3:completemultipartupload": "write",
|
||||||
@@ -61,8 +79,11 @@ ACTION_ALIASES = {
|
|||||||
"delete": "delete",
|
"delete": "delete",
|
||||||
"s3:deleteobject": "delete",
|
"s3:deleteobject": "delete",
|
||||||
"s3:deleteobjectversion": "delete",
|
"s3:deleteobjectversion": "delete",
|
||||||
"s3:deletebucket": "delete",
|
|
||||||
"s3:deleteobjecttagging": "delete",
|
"s3:deleteobjecttagging": "delete",
|
||||||
|
"create_bucket": "create_bucket",
|
||||||
|
"s3:createbucket": "create_bucket",
|
||||||
|
"delete_bucket": "delete_bucket",
|
||||||
|
"s3:deletebucket": "delete_bucket",
|
||||||
"share": "share",
|
"share": "share",
|
||||||
"s3:putobjectacl": "share",
|
"s3:putobjectacl": "share",
|
||||||
"s3:putbucketacl": "share",
|
"s3:putbucketacl": "share",
|
||||||
@@ -88,11 +109,50 @@ ACTION_ALIASES = {
|
|||||||
"s3:getbucketcors": "cors",
|
"s3:getbucketcors": "cors",
|
||||||
"s3:putbucketcors": "cors",
|
"s3:putbucketcors": "cors",
|
||||||
"s3:deletebucketcors": "cors",
|
"s3:deletebucketcors": "cors",
|
||||||
|
"versioning": "versioning",
|
||||||
|
"s3:getbucketversioning": "versioning",
|
||||||
|
"s3:putbucketversioning": "versioning",
|
||||||
|
"tagging": "tagging",
|
||||||
|
"s3:getbuckettagging": "tagging",
|
||||||
|
"s3:putbuckettagging": "tagging",
|
||||||
|
"s3:deletebuckettagging": "tagging",
|
||||||
|
"encryption": "encryption",
|
||||||
|
"s3:getencryptionconfiguration": "encryption",
|
||||||
|
"s3:putencryptionconfiguration": "encryption",
|
||||||
|
"s3:deleteencryptionconfiguration": "encryption",
|
||||||
|
"quota": "quota",
|
||||||
|
"s3:getbucketquota": "quota",
|
||||||
|
"s3:putbucketquota": "quota",
|
||||||
|
"s3:deletebucketquota": "quota",
|
||||||
|
"object_lock": "object_lock",
|
||||||
|
"s3:getobjectlockconfiguration": "object_lock",
|
||||||
|
"s3:putobjectlockconfiguration": "object_lock",
|
||||||
|
"s3:putobjectretention": "object_lock",
|
||||||
|
"s3:getobjectretention": "object_lock",
|
||||||
|
"s3:putobjectlegalhold": "object_lock",
|
||||||
|
"s3:getobjectlegalhold": "object_lock",
|
||||||
|
"notification": "notification",
|
||||||
|
"s3:getbucketnotificationconfiguration": "notification",
|
||||||
|
"s3:putbucketnotificationconfiguration": "notification",
|
||||||
|
"s3:deletebucketnotificationconfiguration": "notification",
|
||||||
|
"logging": "logging",
|
||||||
|
"s3:getbucketlogging": "logging",
|
||||||
|
"s3:putbucketlogging": "logging",
|
||||||
|
"s3:deletebucketlogging": "logging",
|
||||||
|
"website": "website",
|
||||||
|
"s3:getbucketwebsite": "website",
|
||||||
|
"s3:putbucketwebsite": "website",
|
||||||
|
"s3:deletebucketwebsite": "website",
|
||||||
"iam:listusers": "iam:list_users",
|
"iam:listusers": "iam:list_users",
|
||||||
"iam:createuser": "iam:create_user",
|
"iam:createuser": "iam:create_user",
|
||||||
"iam:deleteuser": "iam:delete_user",
|
"iam:deleteuser": "iam:delete_user",
|
||||||
"iam:rotateaccesskey": "iam:rotate_key",
|
"iam:rotateaccesskey": "iam:rotate_key",
|
||||||
"iam:putuserpolicy": "iam:update_policy",
|
"iam:putuserpolicy": "iam:update_policy",
|
||||||
|
"iam:createaccesskey": "iam:create_key",
|
||||||
|
"iam:deleteaccesskey": "iam:delete_key",
|
||||||
|
"iam:getuser": "iam:get_user",
|
||||||
|
"iam:getpolicy": "iam:get_policy",
|
||||||
|
"iam:disableuser": "iam:disable_user",
|
||||||
"iam:*": "iam:*",
|
"iam:*": "iam:*",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +161,7 @@ ACTION_ALIASES = {
|
|||||||
class Policy:
|
class Policy:
|
||||||
bucket: str
|
bucket: str
|
||||||
actions: Set[str]
|
actions: Set[str]
|
||||||
|
prefix: str = "*"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -117,6 +178,16 @@ def _derive_fernet_key(secret: str) -> bytes:
|
|||||||
|
|
||||||
_IAM_ENCRYPTED_PREFIX = b"MYFSIO_IAM_ENC:"
|
_IAM_ENCRYPTED_PREFIX = b"MYFSIO_IAM_ENC:"
|
||||||
|
|
||||||
|
_CONFIG_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_v1_actions(actions: Set[str]) -> Set[str]:
|
||||||
|
expanded = set(actions)
|
||||||
|
for action, implied in _V1_IMPLIED_ACTIONS.items():
|
||||||
|
if action in expanded:
|
||||||
|
expanded.update(implied)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
class IamService:
|
class IamService:
|
||||||
"""Loads IAM configuration, manages users, and evaluates policies."""
|
"""Loads IAM configuration, manages users, and evaluates policies."""
|
||||||
@@ -131,7 +202,10 @@ class IamService:
|
|||||||
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if not self.config_path.exists():
|
if not self.config_path.exists():
|
||||||
self._write_default()
|
self._write_default()
|
||||||
self._users: Dict[str, Dict[str, Any]] = {}
|
self._user_records: Dict[str, Dict[str, Any]] = {}
|
||||||
|
self._key_index: Dict[str, str] = {}
|
||||||
|
self._key_secrets: Dict[str, str] = {}
|
||||||
|
self._key_status: Dict[str, str] = {}
|
||||||
self._raw_config: Dict[str, Any] = {}
|
self._raw_config: Dict[str, Any] = {}
|
||||||
self._failed_attempts: Dict[str, Deque[datetime]] = {}
|
self._failed_attempts: Dict[str, Deque[datetime]] = {}
|
||||||
self._last_load_time = 0.0
|
self._last_load_time = 0.0
|
||||||
@@ -146,7 +220,6 @@ class IamService:
|
|||||||
self._load_lockout_state()
|
self._load_lockout_state()
|
||||||
|
|
||||||
def _maybe_reload(self) -> None:
|
def _maybe_reload(self) -> None:
|
||||||
"""Reload configuration if the file has changed on disk."""
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if now - self._last_stat_check < self._stat_check_interval:
|
if now - self._last_stat_check < self._stat_check_interval:
|
||||||
return
|
return
|
||||||
@@ -183,11 +256,20 @@ class IamService:
|
|||||||
raise IamError(
|
raise IamError(
|
||||||
f"Access temporarily locked. Try again in {seconds} seconds."
|
f"Access temporarily locked. Try again in {seconds} seconds."
|
||||||
)
|
)
|
||||||
record = self._users.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
stored_secret = record["secret_key"] if record else secrets.token_urlsafe(24)
|
stored_secret = self._key_secrets.get(access_key, secrets.token_urlsafe(24))
|
||||||
if not record or not hmac.compare_digest(stored_secret, secret_key):
|
if not user_id or not hmac.compare_digest(stored_secret, secret_key):
|
||||||
self._record_failed_attempt(access_key)
|
self._record_failed_attempt(access_key)
|
||||||
raise IamError("Invalid credentials")
|
raise IamError("Invalid credentials")
|
||||||
|
key_status = self._key_status.get(access_key, "active")
|
||||||
|
if key_status != "active":
|
||||||
|
raise IamError("Access key is inactive")
|
||||||
|
record = self._user_records.get(user_id)
|
||||||
|
if not record:
|
||||||
|
self._record_failed_attempt(access_key)
|
||||||
|
raise IamError("Invalid credentials")
|
||||||
|
if not record.get("enabled", True):
|
||||||
|
raise IamError("User account is disabled")
|
||||||
self._check_expiry(access_key, record)
|
self._check_expiry(access_key, record)
|
||||||
self._clear_failed_attempts(access_key)
|
self._clear_failed_attempts(access_key)
|
||||||
return self._build_principal(access_key, record)
|
return self._build_principal(access_key, record)
|
||||||
@@ -215,7 +297,6 @@ class IamService:
|
|||||||
return self.config_path.parent / "lockout_state.json"
|
return self.config_path.parent / "lockout_state.json"
|
||||||
|
|
||||||
def _load_lockout_state(self) -> None:
|
def _load_lockout_state(self) -> None:
|
||||||
"""Load lockout state from disk."""
|
|
||||||
try:
|
try:
|
||||||
if self._lockout_file().exists():
|
if self._lockout_file().exists():
|
||||||
data = json.loads(self._lockout_file().read_text(encoding="utf-8"))
|
data = json.loads(self._lockout_file().read_text(encoding="utf-8"))
|
||||||
@@ -235,7 +316,6 @@ class IamService:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _save_lockout_state(self) -> None:
|
def _save_lockout_state(self) -> None:
|
||||||
"""Persist lockout state to disk."""
|
|
||||||
data: Dict[str, Any] = {"failed_attempts": {}}
|
data: Dict[str, Any] = {"failed_attempts": {}}
|
||||||
for key, attempts in self._failed_attempts.items():
|
for key, attempts in self._failed_attempts.items():
|
||||||
data["failed_attempts"][key] = [ts.isoformat() for ts in attempts]
|
data["failed_attempts"][key] = [ts.isoformat() for ts in attempts]
|
||||||
@@ -270,10 +350,9 @@ class IamService:
|
|||||||
return int(max(0, self.auth_lockout_window.total_seconds() - elapsed))
|
return int(max(0, self.auth_lockout_window.total_seconds() - elapsed))
|
||||||
|
|
||||||
def create_session_token(self, access_key: str, duration_seconds: int = 3600) -> str:
|
def create_session_token(self, access_key: str, duration_seconds: int = 3600) -> str:
|
||||||
"""Create a temporary session token for an access key."""
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
if not record:
|
if not user_id or user_id not in self._user_records:
|
||||||
raise IamError("Unknown access key")
|
raise IamError("Unknown access key")
|
||||||
self._cleanup_expired_sessions()
|
self._cleanup_expired_sessions()
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
@@ -285,7 +364,6 @@ class IamService:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
def validate_session_token(self, access_key: str, session_token: str) -> bool:
|
def validate_session_token(self, access_key: str, session_token: str) -> bool:
|
||||||
"""Validate a session token for an access key (thread-safe, constant-time)."""
|
|
||||||
dummy_key = secrets.token_urlsafe(16)
|
dummy_key = secrets.token_urlsafe(16)
|
||||||
dummy_token = secrets.token_urlsafe(32)
|
dummy_token = secrets.token_urlsafe(32)
|
||||||
with self._session_lock:
|
with self._session_lock:
|
||||||
@@ -304,7 +382,6 @@ class IamService:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _cleanup_expired_sessions(self) -> None:
|
def _cleanup_expired_sessions(self) -> None:
|
||||||
"""Remove expired session tokens."""
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired = [token for token, data in self._sessions.items() if now > data["expires_at"]]
|
expired = [token for token, data in self._sessions.items() if now > data["expires_at"]]
|
||||||
for token in expired:
|
for token in expired:
|
||||||
@@ -316,13 +393,18 @@ class IamService:
|
|||||||
if cached:
|
if cached:
|
||||||
principal, cached_time = cached
|
principal, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
record = self._users.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
if record:
|
if user_id:
|
||||||
self._check_expiry(access_key, record)
|
record = self._user_records.get(user_id)
|
||||||
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
|
if not user_id:
|
||||||
|
raise IamError("Unknown access key")
|
||||||
|
record = self._user_records.get(user_id)
|
||||||
if not record:
|
if not record:
|
||||||
raise IamError("Unknown access key")
|
raise IamError("Unknown access key")
|
||||||
self._check_expiry(access_key, record)
|
self._check_expiry(access_key, record)
|
||||||
@@ -332,22 +414,26 @@ class IamService:
|
|||||||
|
|
||||||
def secret_for_key(self, access_key: str) -> str:
|
def secret_for_key(self, access_key: str) -> str:
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
secret = self._key_secrets.get(access_key)
|
||||||
if not record:
|
if not secret:
|
||||||
raise IamError("Unknown access key")
|
raise IamError("Unknown access key")
|
||||||
self._check_expiry(access_key, record)
|
user_id = self._key_index.get(access_key)
|
||||||
return record["secret_key"]
|
if user_id:
|
||||||
|
record = self._user_records.get(user_id)
|
||||||
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
|
return secret
|
||||||
|
|
||||||
def authorize(self, principal: Principal, bucket_name: str | None, action: str) -> None:
|
def authorize(self, principal: Principal, bucket_name: str | None, action: str, *, object_key: str | None = None) -> None:
|
||||||
action = self._normalize_action(action)
|
action = self._normalize_action(action)
|
||||||
if action not in ALLOWED_ACTIONS:
|
if action not in ALLOWED_ACTIONS:
|
||||||
raise IamError(f"Unknown action '{action}'")
|
raise IamError(f"Unknown action '{action}'")
|
||||||
bucket_name = bucket_name or "*"
|
bucket_name = bucket_name or "*"
|
||||||
normalized = bucket_name.lower() if bucket_name != "*" else bucket_name
|
normalized = bucket_name.lower() if bucket_name != "*" else bucket_name
|
||||||
if not self._is_allowed(principal, normalized, action):
|
if not self._is_allowed(principal, normalized, action, object_key=object_key):
|
||||||
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
|
raise IamError(f"Access denied for action '{action}' on bucket '{bucket_name}'")
|
||||||
|
|
||||||
def check_permissions(self, principal: Principal, bucket_name: str | None, actions: Iterable[str]) -> Dict[str, bool]:
|
def check_permissions(self, principal: Principal, bucket_name: str | None, actions: Iterable[str], *, object_key: str | None = None) -> Dict[str, bool]:
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
bucket_name = (bucket_name or "*").lower() if bucket_name != "*" else (bucket_name or "*")
|
bucket_name = (bucket_name or "*").lower() if bucket_name != "*" else (bucket_name or "*")
|
||||||
normalized_actions = {a: self._normalize_action(a) for a in actions}
|
normalized_actions = {a: self._normalize_action(a) for a in actions}
|
||||||
@@ -356,37 +442,53 @@ class IamService:
|
|||||||
if canonical not in ALLOWED_ACTIONS:
|
if canonical not in ALLOWED_ACTIONS:
|
||||||
results[original] = False
|
results[original] = False
|
||||||
else:
|
else:
|
||||||
results[original] = self._is_allowed(principal, bucket_name, canonical)
|
results[original] = self._is_allowed(principal, bucket_name, canonical, object_key=object_key)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
|
def buckets_for_principal(self, principal: Principal, buckets: Iterable[str]) -> List[str]:
|
||||||
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
|
return [bucket for bucket in buckets if self._is_allowed(principal, bucket, "list")]
|
||||||
|
|
||||||
def _is_allowed(self, principal: Principal, bucket_name: str, action: str) -> bool:
|
def _is_allowed(self, principal: Principal, bucket_name: str, action: str, *, object_key: str | None = None) -> bool:
|
||||||
bucket_name = bucket_name.lower()
|
bucket_name = bucket_name.lower()
|
||||||
for policy in principal.policies:
|
for policy in principal.policies:
|
||||||
if policy.bucket not in {"*", bucket_name}:
|
if policy.bucket not in {"*", bucket_name}:
|
||||||
continue
|
continue
|
||||||
if "*" in policy.actions or action in policy.actions:
|
action_match = "*" in policy.actions or action in policy.actions
|
||||||
return True
|
if not action_match and "iam:*" in policy.actions and action.startswith("iam:"):
|
||||||
if "iam:*" in policy.actions and action.startswith("iam:"):
|
action_match = True
|
||||||
return True
|
if not action_match:
|
||||||
|
continue
|
||||||
|
if object_key is not None and policy.prefix != "*":
|
||||||
|
prefix = policy.prefix.rstrip("*")
|
||||||
|
if not object_key.startswith(prefix):
|
||||||
|
continue
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def list_users(self) -> List[Dict[str, Any]]:
|
def list_users(self) -> List[Dict[str, Any]]:
|
||||||
listing: List[Dict[str, Any]] = []
|
listing: List[Dict[str, Any]] = []
|
||||||
for access_key, record in self._users.items():
|
for user_id, record in self._user_records.items():
|
||||||
listing.append(
|
access_keys = []
|
||||||
{
|
for key_info in record.get("access_keys", []):
|
||||||
"access_key": access_key,
|
access_keys.append({
|
||||||
"display_name": record["display_name"],
|
"access_key": key_info["access_key"],
|
||||||
"expires_at": record.get("expires_at"),
|
"status": key_info.get("status", "active"),
|
||||||
"policies": [
|
"created_at": key_info.get("created_at"),
|
||||||
{"bucket": policy.bucket, "actions": sorted(policy.actions)}
|
})
|
||||||
for policy in record["policies"]
|
user_entry: Dict[str, Any] = {
|
||||||
],
|
"user_id": user_id,
|
||||||
}
|
"display_name": record["display_name"],
|
||||||
)
|
"enabled": record.get("enabled", True),
|
||||||
|
"expires_at": record.get("expires_at"),
|
||||||
|
"access_keys": access_keys,
|
||||||
|
"policies": [
|
||||||
|
{"bucket": policy.bucket, "actions": sorted(policy.actions), "prefix": policy.prefix}
|
||||||
|
for policy in record["policies"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if access_keys:
|
||||||
|
user_entry["access_key"] = access_keys[0]["access_key"]
|
||||||
|
listing.append(user_entry)
|
||||||
return listing
|
return listing
|
||||||
|
|
||||||
def create_user(
|
def create_user(
|
||||||
@@ -397,20 +499,33 @@ class IamService:
|
|||||||
access_key: str | None = None,
|
access_key: str | None = None,
|
||||||
secret_key: str | None = None,
|
secret_key: str | None = None,
|
||||||
expires_at: str | None = None,
|
expires_at: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
access_key = (access_key or self._generate_access_key()).strip()
|
access_key = (access_key or self._generate_access_key()).strip()
|
||||||
if not access_key:
|
if not access_key:
|
||||||
raise IamError("Access key cannot be empty")
|
raise IamError("Access key cannot be empty")
|
||||||
if access_key in self._users:
|
if access_key in self._key_index:
|
||||||
raise IamError("Access key already exists")
|
raise IamError("Access key already exists")
|
||||||
if expires_at:
|
if expires_at:
|
||||||
self._validate_expires_at(expires_at)
|
self._validate_expires_at(expires_at)
|
||||||
secret_key = secret_key or self._generate_secret_key()
|
secret_key = secret_key or self._generate_secret_key()
|
||||||
sanitized_policies = self._prepare_policy_payload(policies)
|
sanitized_policies = self._prepare_policy_payload(policies)
|
||||||
|
user_id = user_id or self._generate_user_id()
|
||||||
|
if user_id in self._user_records:
|
||||||
|
raise IamError("User ID already exists")
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
record: Dict[str, Any] = {
|
record: Dict[str, Any] = {
|
||||||
"access_key": access_key,
|
"user_id": user_id,
|
||||||
"secret_key": secret_key,
|
|
||||||
"display_name": display_name or access_key,
|
"display_name": display_name or access_key,
|
||||||
|
"enabled": True,
|
||||||
|
"access_keys": [
|
||||||
|
{
|
||||||
|
"access_key": access_key,
|
||||||
|
"secret_key": secret_key,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": now_iso,
|
||||||
|
}
|
||||||
|
],
|
||||||
"policies": sanitized_policies,
|
"policies": sanitized_policies,
|
||||||
}
|
}
|
||||||
if expires_at:
|
if expires_at:
|
||||||
@@ -418,12 +533,108 @@ class IamService:
|
|||||||
self._raw_config.setdefault("users", []).append(record)
|
self._raw_config.setdefault("users", []).append(record)
|
||||||
self._save()
|
self._save()
|
||||||
self._load()
|
self._load()
|
||||||
return {"access_key": access_key, "secret_key": secret_key}
|
return {"user_id": user_id, "access_key": access_key, "secret_key": secret_key}
|
||||||
|
|
||||||
|
def create_access_key(self, identifier: str) -> Dict[str, str]:
|
||||||
|
user_raw, _ = self._resolve_raw_user(identifier)
|
||||||
|
new_access_key = self._generate_access_key()
|
||||||
|
new_secret_key = self._generate_secret_key()
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
key_entry = {
|
||||||
|
"access_key": new_access_key,
|
||||||
|
"secret_key": new_secret_key,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": now_iso,
|
||||||
|
}
|
||||||
|
user_raw.setdefault("access_keys", []).append(key_entry)
|
||||||
|
self._save()
|
||||||
|
self._load()
|
||||||
|
return {"access_key": new_access_key, "secret_key": new_secret_key}
|
||||||
|
|
||||||
|
def delete_access_key(self, access_key: str) -> None:
|
||||||
|
user_raw, _ = self._resolve_raw_user(access_key)
|
||||||
|
keys = user_raw.get("access_keys", [])
|
||||||
|
if len(keys) <= 1:
|
||||||
|
raise IamError("Cannot delete the only access key for a user")
|
||||||
|
remaining = [k for k in keys if k["access_key"] != access_key]
|
||||||
|
if len(remaining) == len(keys):
|
||||||
|
raise IamError("Access key not found")
|
||||||
|
user_raw["access_keys"] = remaining
|
||||||
|
self._save()
|
||||||
|
self._principal_cache.pop(access_key, None)
|
||||||
|
self._secret_key_cache.pop(access_key, None)
|
||||||
|
from .s3_api import clear_signing_key_cache
|
||||||
|
clear_signing_key_cache()
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def disable_user(self, identifier: str) -> None:
|
||||||
|
user_raw, _ = self._resolve_raw_user(identifier)
|
||||||
|
user_raw["enabled"] = False
|
||||||
|
self._save()
|
||||||
|
for key_info in user_raw.get("access_keys", []):
|
||||||
|
ak = key_info["access_key"]
|
||||||
|
self._principal_cache.pop(ak, None)
|
||||||
|
self._secret_key_cache.pop(ak, None)
|
||||||
|
from .s3_api import clear_signing_key_cache
|
||||||
|
clear_signing_key_cache()
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def enable_user(self, identifier: str) -> None:
|
||||||
|
user_raw, _ = self._resolve_raw_user(identifier)
|
||||||
|
user_raw["enabled"] = True
|
||||||
|
self._save()
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
def get_user_by_id(self, user_id: str) -> Dict[str, Any]:
|
||||||
|
record = self._user_records.get(user_id)
|
||||||
|
if not record:
|
||||||
|
raise IamError("User not found")
|
||||||
|
access_keys = []
|
||||||
|
for key_info in record.get("access_keys", []):
|
||||||
|
access_keys.append({
|
||||||
|
"access_key": key_info["access_key"],
|
||||||
|
"status": key_info.get("status", "active"),
|
||||||
|
"created_at": key_info.get("created_at"),
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"user_id": user_id,
|
||||||
|
"display_name": record["display_name"],
|
||||||
|
"enabled": record.get("enabled", True),
|
||||||
|
"expires_at": record.get("expires_at"),
|
||||||
|
"access_keys": access_keys,
|
||||||
|
"policies": [
|
||||||
|
{"bucket": p.bucket, "actions": sorted(p.actions), "prefix": p.prefix}
|
||||||
|
for p in record["policies"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_user_policies(self, identifier: str) -> List[Dict[str, Any]]:
|
||||||
|
_, user_id = self._resolve_raw_user(identifier)
|
||||||
|
record = self._user_records.get(user_id)
|
||||||
|
if not record:
|
||||||
|
raise IamError("User not found")
|
||||||
|
return [
|
||||||
|
{"bucket": p.bucket, "actions": sorted(p.actions), "prefix": p.prefix}
|
||||||
|
for p in record["policies"]
|
||||||
|
]
|
||||||
|
|
||||||
|
def resolve_user_id(self, identifier: str) -> str:
|
||||||
|
if identifier in self._user_records:
|
||||||
|
return identifier
|
||||||
|
user_id = self._key_index.get(identifier)
|
||||||
|
if user_id:
|
||||||
|
return user_id
|
||||||
|
raise IamError("User not found")
|
||||||
|
|
||||||
def rotate_secret(self, access_key: str) -> str:
|
def rotate_secret(self, access_key: str) -> str:
|
||||||
user = self._get_raw_user(access_key)
|
user_raw, _ = self._resolve_raw_user(access_key)
|
||||||
new_secret = self._generate_secret_key()
|
new_secret = self._generate_secret_key()
|
||||||
user["secret_key"] = new_secret
|
for key_info in user_raw.get("access_keys", []):
|
||||||
|
if key_info["access_key"] == access_key:
|
||||||
|
key_info["secret_key"] = new_secret
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise IamError("Access key not found")
|
||||||
self._save()
|
self._save()
|
||||||
self._principal_cache.pop(access_key, None)
|
self._principal_cache.pop(access_key, None)
|
||||||
self._secret_key_cache.pop(access_key, None)
|
self._secret_key_cache.pop(access_key, None)
|
||||||
@@ -433,8 +644,8 @@ class IamService:
|
|||||||
return new_secret
|
return new_secret
|
||||||
|
|
||||||
def update_user(self, access_key: str, display_name: str) -> None:
|
def update_user(self, access_key: str, display_name: str) -> None:
|
||||||
user = self._get_raw_user(access_key)
|
user_raw, _ = self._resolve_raw_user(access_key)
|
||||||
user["display_name"] = display_name
|
user_raw["display_name"] = display_name
|
||||||
self._save()
|
self._save()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
@@ -442,32 +653,43 @@ class IamService:
|
|||||||
users = self._raw_config.get("users", [])
|
users = self._raw_config.get("users", [])
|
||||||
if len(users) <= 1:
|
if len(users) <= 1:
|
||||||
raise IamError("Cannot delete the only user")
|
raise IamError("Cannot delete the only user")
|
||||||
remaining = [user for user in users if user["access_key"] != access_key]
|
_, target_user_id = self._resolve_raw_user(access_key)
|
||||||
if len(remaining) == len(users):
|
target_user_raw = None
|
||||||
|
remaining = []
|
||||||
|
for u in users:
|
||||||
|
if u.get("user_id") == target_user_id:
|
||||||
|
target_user_raw = u
|
||||||
|
else:
|
||||||
|
remaining.append(u)
|
||||||
|
if target_user_raw is None:
|
||||||
raise IamError("User not found")
|
raise IamError("User not found")
|
||||||
self._raw_config["users"] = remaining
|
self._raw_config["users"] = remaining
|
||||||
self._save()
|
self._save()
|
||||||
self._principal_cache.pop(access_key, None)
|
for key_info in target_user_raw.get("access_keys", []):
|
||||||
self._secret_key_cache.pop(access_key, None)
|
ak = key_info["access_key"]
|
||||||
|
self._principal_cache.pop(ak, None)
|
||||||
|
self._secret_key_cache.pop(ak, None)
|
||||||
from .s3_api import clear_signing_key_cache
|
from .s3_api import clear_signing_key_cache
|
||||||
clear_signing_key_cache()
|
clear_signing_key_cache()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def update_user_expiry(self, access_key: str, expires_at: str | None) -> None:
|
def update_user_expiry(self, access_key: str, expires_at: str | None) -> None:
|
||||||
user = self._get_raw_user(access_key)
|
user_raw, _ = self._resolve_raw_user(access_key)
|
||||||
if expires_at:
|
if expires_at:
|
||||||
self._validate_expires_at(expires_at)
|
self._validate_expires_at(expires_at)
|
||||||
user["expires_at"] = expires_at
|
user_raw["expires_at"] = expires_at
|
||||||
else:
|
else:
|
||||||
user.pop("expires_at", None)
|
user_raw.pop("expires_at", None)
|
||||||
self._save()
|
self._save()
|
||||||
self._principal_cache.pop(access_key, None)
|
for key_info in user_raw.get("access_keys", []):
|
||||||
self._secret_key_cache.pop(access_key, None)
|
ak = key_info["access_key"]
|
||||||
|
self._principal_cache.pop(ak, None)
|
||||||
|
self._secret_key_cache.pop(ak, None)
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None:
|
def update_user_policies(self, access_key: str, policies: Sequence[Dict[str, Any]]) -> None:
|
||||||
user = self._get_raw_user(access_key)
|
user_raw, _ = self._resolve_raw_user(access_key)
|
||||||
user["policies"] = self._prepare_policy_payload(policies)
|
user_raw["policies"] = self._prepare_policy_payload(policies)
|
||||||
self._save()
|
self._save()
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
@@ -482,6 +704,52 @@ class IamService:
|
|||||||
raise IamError("Cannot decrypt IAM config. SECRET_KEY may have changed. Use 'python run.py reset-cred' to reset credentials.")
|
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")
|
return raw_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
def _is_v2_config(self, raw: Dict[str, Any]) -> bool:
|
||||||
|
return raw.get("version", 1) >= _CONFIG_VERSION
|
||||||
|
|
||||||
|
def _migrate_v1_to_v2(self, raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
migrated_users = []
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
for user in raw.get("users", []):
|
||||||
|
old_policies = user.get("policies", [])
|
||||||
|
expanded_policies = []
|
||||||
|
for p in old_policies:
|
||||||
|
raw_actions = p.get("actions", [])
|
||||||
|
if isinstance(raw_actions, str):
|
||||||
|
raw_actions = [raw_actions]
|
||||||
|
action_set: Set[str] = set()
|
||||||
|
for a in raw_actions:
|
||||||
|
canonical = self._normalize_action(a)
|
||||||
|
if canonical == "*":
|
||||||
|
action_set = set(ALLOWED_ACTIONS)
|
||||||
|
break
|
||||||
|
if canonical:
|
||||||
|
action_set.add(canonical)
|
||||||
|
action_set = _expand_v1_actions(action_set)
|
||||||
|
expanded_policies.append({
|
||||||
|
"bucket": p.get("bucket", "*"),
|
||||||
|
"actions": sorted(action_set),
|
||||||
|
"prefix": p.get("prefix", "*"),
|
||||||
|
})
|
||||||
|
migrated_user: Dict[str, Any] = {
|
||||||
|
"user_id": user["access_key"],
|
||||||
|
"display_name": user.get("display_name", user["access_key"]),
|
||||||
|
"enabled": True,
|
||||||
|
"access_keys": [
|
||||||
|
{
|
||||||
|
"access_key": user["access_key"],
|
||||||
|
"secret_key": user["secret_key"],
|
||||||
|
"status": "active",
|
||||||
|
"created_at": now_iso,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"policies": expanded_policies,
|
||||||
|
}
|
||||||
|
if user.get("expires_at"):
|
||||||
|
migrated_user["expires_at"] = user["expires_at"]
|
||||||
|
migrated_users.append(migrated_user)
|
||||||
|
return {"version": _CONFIG_VERSION, "users": migrated_users}
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
try:
|
try:
|
||||||
self._last_load_time = self.config_path.stat().st_mtime
|
self._last_load_time = self.config_path.stat().st_mtime
|
||||||
@@ -500,35 +768,67 @@ class IamService:
|
|||||||
raise IamError(f"Failed to load IAM config: {e}")
|
raise IamError(f"Failed to load IAM config: {e}")
|
||||||
|
|
||||||
was_plaintext = not raw_bytes.startswith(_IAM_ENCRYPTED_PREFIX)
|
was_plaintext = not raw_bytes.startswith(_IAM_ENCRYPTED_PREFIX)
|
||||||
|
was_v1 = not self._is_v2_config(raw)
|
||||||
|
|
||||||
|
if was_v1:
|
||||||
|
raw = self._migrate_v1_to_v2(raw)
|
||||||
|
|
||||||
|
user_records: Dict[str, Dict[str, Any]] = {}
|
||||||
|
key_index: Dict[str, str] = {}
|
||||||
|
key_secrets: Dict[str, str] = {}
|
||||||
|
key_status_map: Dict[str, str] = {}
|
||||||
|
|
||||||
users: Dict[str, Dict[str, Any]] = {}
|
|
||||||
for user in raw.get("users", []):
|
for user in raw.get("users", []):
|
||||||
|
user_id = user["user_id"]
|
||||||
policies = self._build_policy_objects(user.get("policies", []))
|
policies = self._build_policy_objects(user.get("policies", []))
|
||||||
user_record: Dict[str, Any] = {
|
access_keys_raw = user.get("access_keys", [])
|
||||||
"secret_key": user["secret_key"],
|
access_keys_info = []
|
||||||
"display_name": user.get("display_name", user["access_key"]),
|
for key_entry in access_keys_raw:
|
||||||
|
ak = key_entry["access_key"]
|
||||||
|
sk = key_entry["secret_key"]
|
||||||
|
status = key_entry.get("status", "active")
|
||||||
|
key_index[ak] = user_id
|
||||||
|
key_secrets[ak] = sk
|
||||||
|
key_status_map[ak] = status
|
||||||
|
access_keys_info.append({
|
||||||
|
"access_key": ak,
|
||||||
|
"secret_key": sk,
|
||||||
|
"status": status,
|
||||||
|
"created_at": key_entry.get("created_at"),
|
||||||
|
})
|
||||||
|
record: Dict[str, Any] = {
|
||||||
|
"display_name": user.get("display_name", user_id),
|
||||||
|
"enabled": user.get("enabled", True),
|
||||||
"policies": policies,
|
"policies": policies,
|
||||||
|
"access_keys": access_keys_info,
|
||||||
}
|
}
|
||||||
if user.get("expires_at"):
|
if user.get("expires_at"):
|
||||||
user_record["expires_at"] = user["expires_at"]
|
record["expires_at"] = user["expires_at"]
|
||||||
users[user["access_key"]] = user_record
|
user_records[user_id] = record
|
||||||
if not users:
|
|
||||||
raise IamError("IAM configuration contains no users")
|
|
||||||
self._users = 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:
|
if not user_records:
|
||||||
|
raise IamError("IAM configuration contains no users")
|
||||||
|
|
||||||
|
self._user_records = user_records
|
||||||
|
self._key_index = key_index
|
||||||
|
self._key_secrets = key_secrets
|
||||||
|
self._key_status = key_status_map
|
||||||
|
|
||||||
|
raw_users: List[Dict[str, Any]] = []
|
||||||
|
for user in raw.get("users", []):
|
||||||
|
raw_entry: Dict[str, Any] = {
|
||||||
|
"user_id": user["user_id"],
|
||||||
|
"display_name": user.get("display_name", user["user_id"]),
|
||||||
|
"enabled": user.get("enabled", True),
|
||||||
|
"access_keys": user.get("access_keys", []),
|
||||||
|
"policies": user.get("policies", []),
|
||||||
|
}
|
||||||
|
if user.get("expires_at"):
|
||||||
|
raw_entry["expires_at"] = user["expires_at"]
|
||||||
|
raw_users.append(raw_entry)
|
||||||
|
self._raw_config = {"version": _CONFIG_VERSION, "users": raw_users}
|
||||||
|
|
||||||
|
if was_v1 or (was_plaintext and self._fernet):
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
def _save(self) -> None:
|
def _save(self) -> None:
|
||||||
@@ -547,19 +847,30 @@ class IamService:
|
|||||||
def config_summary(self) -> Dict[str, Any]:
|
def config_summary(self) -> Dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"path": str(self.config_path),
|
"path": str(self.config_path),
|
||||||
"user_count": len(self._users),
|
"user_count": len(self._user_records),
|
||||||
"allowed_actions": sorted(ALLOWED_ACTIONS),
|
"allowed_actions": sorted(ALLOWED_ACTIONS),
|
||||||
}
|
}
|
||||||
|
|
||||||
def export_config(self, mask_secrets: bool = True) -> Dict[str, Any]:
|
def export_config(self, mask_secrets: bool = True) -> Dict[str, Any]:
|
||||||
payload: Dict[str, Any] = {"users": []}
|
payload: Dict[str, Any] = {"version": _CONFIG_VERSION, "users": []}
|
||||||
for user in self._raw_config.get("users", []):
|
for user in self._raw_config.get("users", []):
|
||||||
|
access_keys = []
|
||||||
|
for key_info in user.get("access_keys", []):
|
||||||
|
access_keys.append({
|
||||||
|
"access_key": key_info["access_key"],
|
||||||
|
"secret_key": "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" if mask_secrets else key_info["secret_key"],
|
||||||
|
"status": key_info.get("status", "active"),
|
||||||
|
"created_at": key_info.get("created_at"),
|
||||||
|
})
|
||||||
record: Dict[str, Any] = {
|
record: Dict[str, Any] = {
|
||||||
"access_key": user["access_key"],
|
"user_id": user["user_id"],
|
||||||
"secret_key": "••••••••••" if mask_secrets else user["secret_key"],
|
|
||||||
"display_name": user["display_name"],
|
"display_name": user["display_name"],
|
||||||
|
"enabled": user.get("enabled", True),
|
||||||
|
"access_keys": access_keys,
|
||||||
"policies": user["policies"],
|
"policies": user["policies"],
|
||||||
}
|
}
|
||||||
|
if access_keys:
|
||||||
|
record["access_key"] = access_keys[0]["access_key"]
|
||||||
if user.get("expires_at"):
|
if user.get("expires_at"):
|
||||||
record["expires_at"] = user["expires_at"]
|
record["expires_at"] = user["expires_at"]
|
||||||
payload["users"].append(record)
|
payload["users"].append(record)
|
||||||
@@ -569,6 +880,7 @@ class IamService:
|
|||||||
entries: List[Policy] = []
|
entries: List[Policy] = []
|
||||||
for policy in policies:
|
for policy in policies:
|
||||||
bucket = str(policy.get("bucket", "*")).lower()
|
bucket = str(policy.get("bucket", "*")).lower()
|
||||||
|
prefix = str(policy.get("prefix", "*"))
|
||||||
raw_actions = policy.get("actions", [])
|
raw_actions = policy.get("actions", [])
|
||||||
if isinstance(raw_actions, str):
|
if isinstance(raw_actions, str):
|
||||||
raw_actions = [raw_actions]
|
raw_actions = [raw_actions]
|
||||||
@@ -581,7 +893,7 @@ class IamService:
|
|||||||
if canonical:
|
if canonical:
|
||||||
action_set.add(canonical)
|
action_set.add(canonical)
|
||||||
if action_set:
|
if action_set:
|
||||||
entries.append(Policy(bucket=bucket, actions=action_set))
|
entries.append(Policy(bucket=bucket, actions=action_set, prefix=prefix))
|
||||||
return entries
|
return entries
|
||||||
|
|
||||||
def _prepare_policy_payload(self, policies: Optional[Sequence[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
def _prepare_policy_payload(self, policies: Optional[Sequence[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
||||||
@@ -589,12 +901,14 @@ class IamService:
|
|||||||
policies = (
|
policies = (
|
||||||
{
|
{
|
||||||
"bucket": "*",
|
"bucket": "*",
|
||||||
"actions": ["list", "read", "write", "delete", "share", "policy"],
|
"actions": ["list", "read", "write", "delete", "share", "policy",
|
||||||
|
"create_bucket", "delete_bucket"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
sanitized: List[Dict[str, Any]] = []
|
sanitized: List[Dict[str, Any]] = []
|
||||||
for policy in policies:
|
for policy in policies:
|
||||||
bucket = str(policy.get("bucket", "*")).lower()
|
bucket = str(policy.get("bucket", "*")).lower()
|
||||||
|
prefix = str(policy.get("prefix", "*"))
|
||||||
raw_actions = policy.get("actions", [])
|
raw_actions = policy.get("actions", [])
|
||||||
if isinstance(raw_actions, str):
|
if isinstance(raw_actions, str):
|
||||||
raw_actions = [raw_actions]
|
raw_actions = [raw_actions]
|
||||||
@@ -608,7 +922,10 @@ class IamService:
|
|||||||
action_set.add(canonical)
|
action_set.add(canonical)
|
||||||
if not action_set:
|
if not action_set:
|
||||||
continue
|
continue
|
||||||
sanitized.append({"bucket": bucket, "actions": sorted(action_set)})
|
entry: Dict[str, Any] = {"bucket": bucket, "actions": sorted(action_set)}
|
||||||
|
if prefix != "*":
|
||||||
|
entry["prefix"] = prefix
|
||||||
|
sanitized.append(entry)
|
||||||
if not sanitized:
|
if not sanitized:
|
||||||
raise IamError("At least one policy with valid actions is required")
|
raise IamError("At least one policy with valid actions is required")
|
||||||
return sanitized
|
return sanitized
|
||||||
@@ -633,12 +950,23 @@ class IamService:
|
|||||||
access_key = os.environ.get("ADMIN_ACCESS_KEY", "").strip() or secrets.token_hex(12)
|
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)
|
secret_key = os.environ.get("ADMIN_SECRET_KEY", "").strip() or secrets.token_urlsafe(32)
|
||||||
custom_keys = bool(os.environ.get("ADMIN_ACCESS_KEY", "").strip())
|
custom_keys = bool(os.environ.get("ADMIN_ACCESS_KEY", "").strip())
|
||||||
|
user_id = self._generate_user_id()
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
default = {
|
default = {
|
||||||
|
"version": _CONFIG_VERSION,
|
||||||
"users": [
|
"users": [
|
||||||
{
|
{
|
||||||
"access_key": access_key,
|
"user_id": user_id,
|
||||||
"secret_key": secret_key,
|
|
||||||
"display_name": "Local Admin",
|
"display_name": "Local Admin",
|
||||||
|
"enabled": True,
|
||||||
|
"access_keys": [
|
||||||
|
{
|
||||||
|
"access_key": access_key,
|
||||||
|
"secret_key": secret_key,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": now_iso,
|
||||||
|
}
|
||||||
|
],
|
||||||
"policies": [
|
"policies": [
|
||||||
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
||||||
],
|
],
|
||||||
@@ -660,6 +988,7 @@ class IamService:
|
|||||||
else:
|
else:
|
||||||
print(f"Access Key: {access_key}")
|
print(f"Access Key: {access_key}")
|
||||||
print(f"Secret Key: {secret_key}")
|
print(f"Secret Key: {secret_key}")
|
||||||
|
print(f"User ID: {user_id}")
|
||||||
print(f"{'='*60}")
|
print(f"{'='*60}")
|
||||||
if self._fernet:
|
if self._fernet:
|
||||||
print("IAM config is encrypted at rest.")
|
print("IAM config is encrypted at rest.")
|
||||||
@@ -682,30 +1011,46 @@ class IamService:
|
|||||||
def _generate_secret_key(self) -> str:
|
def _generate_secret_key(self) -> str:
|
||||||
return secrets.token_urlsafe(24)
|
return secrets.token_urlsafe(24)
|
||||||
|
|
||||||
def _get_raw_user(self, access_key: str) -> Dict[str, Any]:
|
def _generate_user_id(self) -> str:
|
||||||
|
return f"u-{secrets.token_hex(8)}"
|
||||||
|
|
||||||
|
def _resolve_raw_user(self, identifier: str) -> Tuple[Dict[str, Any], str]:
|
||||||
for user in self._raw_config.get("users", []):
|
for user in self._raw_config.get("users", []):
|
||||||
if user["access_key"] == access_key:
|
if user.get("user_id") == identifier:
|
||||||
return user
|
return user, identifier
|
||||||
|
for user in self._raw_config.get("users", []):
|
||||||
|
for key_info in user.get("access_keys", []):
|
||||||
|
if key_info["access_key"] == identifier:
|
||||||
|
return user, user["user_id"]
|
||||||
raise IamError("User not found")
|
raise IamError("User not found")
|
||||||
|
|
||||||
|
def _get_raw_user(self, access_key: str) -> Dict[str, Any]:
|
||||||
|
user, _ = self._resolve_raw_user(access_key)
|
||||||
|
return user
|
||||||
|
|
||||||
def get_secret_key(self, access_key: str) -> str | None:
|
def get_secret_key(self, access_key: str) -> str | None:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = self._secret_key_cache.get(access_key)
|
cached = self._secret_key_cache.get(access_key)
|
||||||
if cached:
|
if cached:
|
||||||
secret_key, cached_time = cached
|
secret_key, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
record = self._users.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
if record:
|
if user_id:
|
||||||
self._check_expiry(access_key, record)
|
record = self._user_records.get(user_id)
|
||||||
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
return secret_key
|
return secret_key
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
secret = self._key_secrets.get(access_key)
|
||||||
if record:
|
if secret:
|
||||||
self._check_expiry(access_key, record)
|
user_id = self._key_index.get(access_key)
|
||||||
secret_key = record["secret_key"]
|
if user_id:
|
||||||
self._secret_key_cache[access_key] = (secret_key, now)
|
record = self._user_records.get(user_id)
|
||||||
return secret_key
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
|
self._secret_key_cache[access_key] = (secret, now)
|
||||||
|
return secret
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_principal(self, access_key: str) -> Principal | None:
|
def get_principal(self, access_key: str) -> Principal | None:
|
||||||
@@ -714,16 +1059,20 @@ class IamService:
|
|||||||
if cached:
|
if cached:
|
||||||
principal, cached_time = cached
|
principal, cached_time = cached
|
||||||
if now - cached_time < self._cache_ttl:
|
if now - cached_time < self._cache_ttl:
|
||||||
record = self._users.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
if record:
|
if user_id:
|
||||||
self._check_expiry(access_key, record)
|
record = self._user_records.get(user_id)
|
||||||
|
if record:
|
||||||
|
self._check_expiry(access_key, record)
|
||||||
return principal
|
return principal
|
||||||
|
|
||||||
self._maybe_reload()
|
self._maybe_reload()
|
||||||
record = self._users.get(access_key)
|
user_id = self._key_index.get(access_key)
|
||||||
if record:
|
if user_id:
|
||||||
self._check_expiry(access_key, record)
|
record = self._user_records.get(user_id)
|
||||||
principal = self._build_principal(access_key, record)
|
if record:
|
||||||
self._principal_cache[access_key] = (principal, now)
|
self._check_expiry(access_key, record)
|
||||||
return principal
|
principal = self._build_principal(access_key, record)
|
||||||
|
self._principal_cache[access_key] = (principal, now)
|
||||||
|
return principal
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ def _authorize_action(principal: Principal | None, bucket_name: str | None, acti
|
|||||||
iam_error: IamError | None = None
|
iam_error: IamError | None = None
|
||||||
if principal is not None:
|
if principal is not None:
|
||||||
try:
|
try:
|
||||||
_iam().authorize(principal, bucket_name, action)
|
_iam().authorize(principal, bucket_name, action, object_key=object_key)
|
||||||
iam_allowed = True
|
iam_allowed = True
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
iam_error = exc
|
iam_error = exc
|
||||||
@@ -1135,7 +1135,7 @@ def _bucket_versioning_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "versioning")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
storage = _storage()
|
storage = _storage()
|
||||||
@@ -1182,7 +1182,7 @@ def _bucket_tagging_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "tagging")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
storage = _storage()
|
storage = _storage()
|
||||||
@@ -1347,7 +1347,7 @@ def _bucket_cors_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "cors")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
storage = _storage()
|
storage = _storage()
|
||||||
@@ -1400,7 +1400,7 @@ def _bucket_encryption_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "encryption")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
storage = _storage()
|
storage = _storage()
|
||||||
@@ -1475,7 +1475,7 @@ def _bucket_acl_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "share")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
storage = _storage()
|
storage = _storage()
|
||||||
@@ -1718,12 +1718,12 @@ def _bucket_lifecycle_handler(bucket_name: str) -> Response:
|
|||||||
"""Handle bucket lifecycle configuration (GET/PUT/DELETE /<bucket>?lifecycle)."""
|
"""Handle bucket lifecycle configuration (GET/PUT/DELETE /<bucket>?lifecycle)."""
|
||||||
if request.method not in {"GET", "PUT", "DELETE"}:
|
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||||
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||||
|
|
||||||
principal, error = _require_principal()
|
principal, error = _require_principal()
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "lifecycle")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
@@ -1882,12 +1882,12 @@ def _bucket_quota_handler(bucket_name: str) -> Response:
|
|||||||
"""Handle bucket quota configuration (GET/PUT/DELETE /<bucket>?quota)."""
|
"""Handle bucket quota configuration (GET/PUT/DELETE /<bucket>?quota)."""
|
||||||
if request.method not in {"GET", "PUT", "DELETE"}:
|
if request.method not in {"GET", "PUT", "DELETE"}:
|
||||||
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
return _method_not_allowed(["GET", "PUT", "DELETE"])
|
||||||
|
|
||||||
principal, error = _require_principal()
|
principal, error = _require_principal()
|
||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "quota")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
@@ -1964,7 +1964,7 @@ def _bucket_object_lock_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "object_lock")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
@@ -2010,7 +2010,7 @@ def _bucket_notification_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "notification")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
@@ -2106,7 +2106,7 @@ def _bucket_logging_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "logging")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
@@ -2248,7 +2248,7 @@ def _object_retention_handler(bucket_name: str, object_key: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "write" if request.method == "PUT" else "read", object_key=object_key)
|
_authorize_action(principal, bucket_name, "object_lock", object_key=object_key)
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
@@ -2324,7 +2324,7 @@ def _object_legal_hold_handler(bucket_name: str, object_key: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "write" if request.method == "PUT" else "read", object_key=object_key)
|
_authorize_action(principal, bucket_name, "object_lock", object_key=object_key)
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
|
|
||||||
@@ -2657,7 +2657,7 @@ def bucket_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "write")
|
_authorize_action(principal, bucket_name, "create_bucket")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
try:
|
try:
|
||||||
@@ -2674,7 +2674,7 @@ def bucket_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "delete")
|
_authorize_action(principal, bucket_name, "delete_bucket")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
try:
|
try:
|
||||||
@@ -3229,7 +3229,7 @@ def _bucket_replication_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "replication")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
storage = _storage()
|
storage = _storage()
|
||||||
@@ -3312,7 +3312,7 @@ def _bucket_website_handler(bucket_name: str) -> Response:
|
|||||||
if error:
|
if error:
|
||||||
return error
|
return error
|
||||||
try:
|
try:
|
||||||
_authorize_action(principal, bucket_name, "policy")
|
_authorize_action(principal, bucket_name, "website")
|
||||||
except IamError as exc:
|
except IamError as exc:
|
||||||
return _error_response("AccessDenied", str(exc), 403)
|
return _error_response("AccessDenied", str(exc), 403)
|
||||||
storage = _storage()
|
storage = _storage()
|
||||||
|
|||||||
71
docs.md
71
docs.md
@@ -758,7 +758,7 @@ MyFSIO implements a comprehensive Identity and Access Management (IAM) system th
|
|||||||
- **Create user**: supply a display name, optional JSON inline policy array, and optional credential expiry date.
|
- **Create user**: supply a display name, optional JSON inline policy array, and optional credential expiry date.
|
||||||
- **Set expiry**: assign an expiration date to any user's credentials. Expired credentials are rejected at authentication time. The UI shows expiry badges and preset durations (1h, 24h, 7d, 30d, 90d).
|
- **Set expiry**: assign an expiration date to any user's credentials. Expired credentials are rejected at authentication time. The UI shows expiry badges and preset durations (1h, 24h, 7d, 30d, 90d).
|
||||||
- **Rotate secret**: generates a new secret key; the UI surfaces it once.
|
- **Rotate secret**: generates a new secret key; the UI surfaces it once.
|
||||||
- **Policy editor**: select a user, paste an array of objects (`{"bucket": "*", "actions": ["list", "read"]}`), and submit. Alias support includes AWS-style verbs (e.g., `s3:GetObject`).
|
- **Policy editor**: select a user, paste an array of objects (`{"bucket": "*", "actions": ["list", "read"]}`), and submit. An optional `"prefix"` field restricts object-level actions to a key prefix (e.g., `"uploads/"`). Alias support includes AWS-style verbs (e.g., `s3:GetObject`).
|
||||||
3. Wildcard action `iam:*` is supported for admin user definitions.
|
3. Wildcard action `iam:*` is supported for admin user definitions.
|
||||||
|
|
||||||
> **Breaking Change (v0.2.0+):** Previous versions used fixed default credentials (`localadmin/localadmin`). If upgrading from an older version, your existing credentials remain unchanged, but new installations will generate random credentials.
|
> **Breaking Change (v0.2.0+):** Previous versions used fixed default credentials (`localadmin/localadmin`). If upgrading from an older version, your existing credentials remain unchanged, but new installations will generate random credentials.
|
||||||
@@ -797,13 +797,23 @@ Both layers are evaluated for each request. A user must have permission in their
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `list` | List buckets and objects | `s3:ListBucket`, `s3:ListAllMyBuckets`, `s3:ListBucketVersions`, `s3:ListMultipartUploads`, `s3:ListParts` |
|
| `list` | List buckets and objects | `s3:ListBucket`, `s3:ListAllMyBuckets`, `s3:ListBucketVersions`, `s3:ListMultipartUploads`, `s3:ListParts` |
|
||||||
| `read` | Download objects, get metadata | `s3:GetObject`, `s3:GetObjectVersion`, `s3:GetObjectTagging`, `s3:GetObjectVersionTagging`, `s3:GetObjectAcl`, `s3:GetBucketVersioning`, `s3:HeadObject`, `s3:HeadBucket` |
|
| `read` | Download objects, get metadata | `s3:GetObject`, `s3:GetObjectVersion`, `s3:GetObjectTagging`, `s3:GetObjectVersionTagging`, `s3:GetObjectAcl`, `s3:GetBucketVersioning`, `s3:HeadObject`, `s3:HeadBucket` |
|
||||||
| `write` | Upload objects, create buckets, manage tags | `s3:PutObject`, `s3:CreateBucket`, `s3:PutObjectTagging`, `s3:PutBucketVersioning`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` |
|
| `write` | Upload objects, manage object tags | `s3:PutObject`, `s3:PutObjectTagging`, `s3:CreateMultipartUpload`, `s3:UploadPart`, `s3:CompleteMultipartUpload`, `s3:AbortMultipartUpload`, `s3:CopyObject` |
|
||||||
| `delete` | Remove objects, versions, and buckets | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteBucket`, `s3:DeleteObjectTagging` |
|
| `delete` | Remove objects and versions | `s3:DeleteObject`, `s3:DeleteObjectVersion`, `s3:DeleteObjectTagging` |
|
||||||
|
| `create_bucket` | Create new buckets | `s3:CreateBucket` |
|
||||||
|
| `delete_bucket` | Delete buckets | `s3:DeleteBucket` |
|
||||||
| `share` | Manage Access Control Lists (ACLs) | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` |
|
| `share` | Manage Access Control Lists (ACLs) | `s3:PutObjectAcl`, `s3:PutBucketAcl`, `s3:GetBucketAcl` |
|
||||||
| `policy` | Manage bucket policies | `s3:PutBucketPolicy`, `s3:GetBucketPolicy`, `s3:DeleteBucketPolicy` |
|
| `policy` | Manage bucket policies | `s3:PutBucketPolicy`, `s3:GetBucketPolicy`, `s3:DeleteBucketPolicy` |
|
||||||
|
| `versioning` | Manage bucket versioning configuration | `s3:GetBucketVersioning`, `s3:PutBucketVersioning` |
|
||||||
|
| `tagging` | Manage bucket-level tags | `s3:GetBucketTagging`, `s3:PutBucketTagging`, `s3:DeleteBucketTagging` |
|
||||||
|
| `encryption` | Manage bucket encryption configuration | `s3:GetEncryptionConfiguration`, `s3:PutEncryptionConfiguration`, `s3:DeleteEncryptionConfiguration` |
|
||||||
| `lifecycle` | Manage lifecycle rules | `s3:GetLifecycleConfiguration`, `s3:PutLifecycleConfiguration`, `s3:DeleteLifecycleConfiguration`, `s3:GetBucketLifecycle`, `s3:PutBucketLifecycle` |
|
| `lifecycle` | Manage lifecycle rules | `s3:GetLifecycleConfiguration`, `s3:PutLifecycleConfiguration`, `s3:DeleteLifecycleConfiguration`, `s3:GetBucketLifecycle`, `s3:PutBucketLifecycle` |
|
||||||
| `cors` | Manage CORS configuration | `s3:GetBucketCors`, `s3:PutBucketCors`, `s3:DeleteBucketCors` |
|
| `cors` | Manage CORS configuration | `s3:GetBucketCors`, `s3:PutBucketCors`, `s3:DeleteBucketCors` |
|
||||||
| `replication` | Configure and manage replication | `s3:GetReplicationConfiguration`, `s3:PutReplicationConfiguration`, `s3:DeleteReplicationConfiguration`, `s3:ReplicateObject`, `s3:ReplicateTags`, `s3:ReplicateDelete` |
|
| `replication` | Configure and manage replication | `s3:GetReplicationConfiguration`, `s3:PutReplicationConfiguration`, `s3:DeleteReplicationConfiguration`, `s3:ReplicateObject`, `s3:ReplicateTags`, `s3:ReplicateDelete` |
|
||||||
|
| `quota` | Manage bucket storage quotas | `s3:GetBucketQuota`, `s3:PutBucketQuota`, `s3:DeleteBucketQuota` |
|
||||||
|
| `object_lock` | Manage object lock, retention, and legal holds | `s3:GetObjectLockConfiguration`, `s3:PutObjectLockConfiguration`, `s3:PutObjectRetention`, `s3:GetObjectRetention`, `s3:PutObjectLegalHold`, `s3:GetObjectLegalHold` |
|
||||||
|
| `notification` | Manage bucket event notifications | `s3:GetBucketNotificationConfiguration`, `s3:PutBucketNotificationConfiguration`, `s3:DeleteBucketNotificationConfiguration` |
|
||||||
|
| `logging` | Manage bucket access logging | `s3:GetBucketLogging`, `s3:PutBucketLogging`, `s3:DeleteBucketLogging` |
|
||||||
|
| `website` | Manage static website hosting configuration | `s3:GetBucketWebsite`, `s3:PutBucketWebsite`, `s3:DeleteBucketWebsite` |
|
||||||
|
|
||||||
#### IAM Actions (User Management)
|
#### IAM Actions (User Management)
|
||||||
|
|
||||||
@@ -814,25 +824,31 @@ Both layers are evaluated for each request. A user must have permission in their
|
|||||||
| `iam:delete_user` | Delete IAM users | `iam:DeleteUser` |
|
| `iam:delete_user` | Delete IAM users | `iam:DeleteUser` |
|
||||||
| `iam:rotate_key` | Rotate user secret keys | `iam:RotateAccessKey` |
|
| `iam:rotate_key` | Rotate user secret keys | `iam:RotateAccessKey` |
|
||||||
| `iam:update_policy` | Modify user policies | `iam:PutUserPolicy` |
|
| `iam:update_policy` | Modify user policies | `iam:PutUserPolicy` |
|
||||||
|
| `iam:create_key` | Create additional access keys for a user | `iam:CreateAccessKey` |
|
||||||
|
| `iam:delete_key` | Delete an access key from a user | `iam:DeleteAccessKey` |
|
||||||
|
| `iam:get_user` | View user details and access keys | `iam:GetUser` |
|
||||||
|
| `iam:get_policy` | View user policy configuration | `iam:GetPolicy` |
|
||||||
|
| `iam:disable_user` | Temporarily disable/enable a user account | `iam:DisableUser` |
|
||||||
| `iam:*` | **Admin wildcard** – grants all IAM actions | — |
|
| `iam:*` | **Admin wildcard** – grants all IAM actions | — |
|
||||||
|
|
||||||
#### Wildcards
|
#### Wildcards
|
||||||
|
|
||||||
| Wildcard | Scope | Description |
|
| Wildcard | Scope | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `*` (in actions) | All S3 actions | Grants `list`, `read`, `write`, `delete`, `share`, `policy`, `lifecycle`, `cors`, `replication` |
|
| `*` (in actions) | All S3 actions | Grants all 19 S3 actions including `list`, `read`, `write`, `delete`, `create_bucket`, `delete_bucket`, `share`, `policy`, `versioning`, `tagging`, `encryption`, `lifecycle`, `cors`, `replication`, `quota`, `object_lock`, `notification`, `logging`, `website` |
|
||||||
| `iam:*` | All IAM actions | Grants all `iam:*` actions for user management |
|
| `iam:*` | All IAM actions | Grants all `iam:*` actions for user management |
|
||||||
| `*` (in bucket) | All buckets | Policy applies to every bucket |
|
| `*` (in bucket) | All buckets | Policy applies to every bucket |
|
||||||
|
|
||||||
### IAM Policy Structure
|
### IAM Policy Structure
|
||||||
|
|
||||||
User policies are stored as a JSON array of policy objects. Each object specifies a bucket and the allowed actions:
|
User policies are stored as a JSON array of policy objects. Each object specifies a bucket, the allowed actions, and an optional prefix for object-level scoping:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"bucket": "<bucket-name-or-wildcard>",
|
"bucket": "<bucket-name-or-wildcard>",
|
||||||
"actions": ["<action1>", "<action2>", ...]
|
"actions": ["<action1>", "<action2>", ...],
|
||||||
|
"prefix": "<optional-key-prefix>"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
@@ -840,12 +856,13 @@ User policies are stored as a JSON array of policy objects. Each object specifie
|
|||||||
**Fields:**
|
**Fields:**
|
||||||
- `bucket`: The bucket name (case-insensitive) or `*` for all buckets
|
- `bucket`: The bucket name (case-insensitive) or `*` for all buckets
|
||||||
- `actions`: Array of action strings (simple names or AWS aliases)
|
- `actions`: Array of action strings (simple names or AWS aliases)
|
||||||
|
- `prefix`: *(optional)* Restrict object-level actions to keys starting with this prefix. Defaults to `*` (all objects). Example: `"uploads/"` restricts to keys under `uploads/`
|
||||||
|
|
||||||
### Example User Policies
|
### Example User Policies
|
||||||
|
|
||||||
**Full Administrator (complete system access):**
|
**Full Administrator (complete system access):**
|
||||||
```json
|
```json
|
||||||
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "share", "policy", "lifecycle", "cors", "replication", "iam:*"]}]
|
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "share", "policy", "create_bucket", "delete_bucket", "versioning", "tagging", "encryption", "lifecycle", "cors", "replication", "quota", "object_lock", "notification", "logging", "website", "iam:*"]}]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Read-Only User (browse and download only):**
|
**Read-Only User (browse and download only):**
|
||||||
@@ -858,6 +875,11 @@ User policies are stored as a JSON array of policy objects. Each object specifie
|
|||||||
[{"bucket": "user-bucket", "actions": ["list", "read", "write", "delete"]}]
|
[{"bucket": "user-bucket", "actions": ["list", "read", "write", "delete"]}]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Operator (data operations + bucket management, no config):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "*", "actions": ["list", "read", "write", "delete", "create_bucket", "delete_bucket"]}]
|
||||||
|
```
|
||||||
|
|
||||||
**Multiple Bucket Access (different permissions per bucket):**
|
**Multiple Bucket Access (different permissions per bucket):**
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
@@ -867,9 +889,14 @@ User policies are stored as a JSON array of policy objects. Each object specifie
|
|||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Prefix-Scoped Access (restrict to a folder inside a shared bucket):**
|
||||||
|
```json
|
||||||
|
[{"bucket": "shared-data", "actions": ["list", "read", "write", "delete"], "prefix": "team-a/"}]
|
||||||
|
```
|
||||||
|
|
||||||
**IAM Manager (manage users but no data access):**
|
**IAM Manager (manage users but no data access):**
|
||||||
```json
|
```json
|
||||||
[{"bucket": "*", "actions": ["iam:list_users", "iam:create_user", "iam:delete_user", "iam:rotate_key", "iam:update_policy"]}]
|
[{"bucket": "*", "actions": ["iam:list_users", "iam:create_user", "iam:delete_user", "iam:rotate_key", "iam:update_policy", "iam:create_key", "iam:delete_key", "iam:get_user", "iam:get_policy", "iam:disable_user"]}]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Replication Operator (manage replication only):**
|
**Replication Operator (manage replication only):**
|
||||||
@@ -889,10 +916,10 @@ User policies are stored as a JSON array of policy objects. Each object specifie
|
|||||||
|
|
||||||
**Bucket Administrator (full bucket config, no IAM access):**
|
**Bucket Administrator (full bucket config, no IAM access):**
|
||||||
```json
|
```json
|
||||||
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete", "policy", "lifecycle", "cors"]}]
|
[{"bucket": "my-bucket", "actions": ["list", "read", "write", "delete", "create_bucket", "delete_bucket", "share", "policy", "versioning", "tagging", "encryption", "lifecycle", "cors", "replication", "quota", "object_lock", "notification", "logging", "website"]}]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Upload-Only User (write but cannot read back):**
|
**Upload-Only User (write but cannot create/delete buckets):**
|
||||||
```json
|
```json
|
||||||
[{"bucket": "drop-box", "actions": ["write"]}]
|
[{"bucket": "drop-box", "actions": ["write"]}]
|
||||||
```
|
```
|
||||||
@@ -967,6 +994,30 @@ curl -X POST http://localhost:5000/iam/users/<access-key>/expiry \
|
|||||||
# Delete a user (requires iam:delete_user)
|
# Delete a user (requires iam:delete_user)
|
||||||
curl -X DELETE http://localhost:5000/iam/users/<access-key> \
|
curl -X DELETE http://localhost:5000/iam/users/<access-key> \
|
||||||
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
-H "X-Access-Key: ..." -H "X-Secret-Key: ..."
|
||||||
|
|
||||||
|
# Get user details (requires iam:get_user) — via Admin API
|
||||||
|
curl http://localhost:5000/admin/iam/users/<user-id-or-access-key> \
|
||||||
|
-H "Authorization: AWS4-HMAC-SHA256 ..."
|
||||||
|
|
||||||
|
# Get user policies (requires iam:get_policy) — via Admin API
|
||||||
|
curl http://localhost:5000/admin/iam/users/<user-id-or-access-key>/policies \
|
||||||
|
-H "Authorization: AWS4-HMAC-SHA256 ..."
|
||||||
|
|
||||||
|
# Create additional access key for a user (requires iam:create_key)
|
||||||
|
curl -X POST http://localhost:5000/admin/iam/users/<user-id-or-access-key>/keys \
|
||||||
|
-H "Authorization: AWS4-HMAC-SHA256 ..."
|
||||||
|
|
||||||
|
# Delete an access key (requires iam:delete_key)
|
||||||
|
curl -X DELETE http://localhost:5000/admin/iam/users/<user-id>/keys/<access-key> \
|
||||||
|
-H "Authorization: AWS4-HMAC-SHA256 ..."
|
||||||
|
|
||||||
|
# Disable a user account (requires iam:disable_user)
|
||||||
|
curl -X POST http://localhost:5000/admin/iam/users/<user-id-or-access-key>/disable \
|
||||||
|
-H "Authorization: AWS4-HMAC-SHA256 ..."
|
||||||
|
|
||||||
|
# Re-enable a user account (requires iam:disable_user)
|
||||||
|
curl -X POST http://localhost:5000/admin/iam/users/<user-id-or-access-key>/enable \
|
||||||
|
-H "Authorization: AWS4-HMAC-SHA256 ..."
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permission Precedence
|
### Permission Precedence
|
||||||
|
|||||||
37
run.py
37
run.py
@@ -128,6 +128,7 @@ def reset_credentials() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if raw_config and raw_config.get("users"):
|
if raw_config and raw_config.get("users"):
|
||||||
|
is_v2 = raw_config.get("version", 1) >= 2
|
||||||
admin_user = None
|
admin_user = None
|
||||||
for user in raw_config["users"]:
|
for user in raw_config["users"]:
|
||||||
policies = user.get("policies", [])
|
policies = user.get("policies", [])
|
||||||
@@ -141,15 +142,39 @@ def reset_credentials() -> None:
|
|||||||
if not admin_user:
|
if not admin_user:
|
||||||
admin_user = raw_config["users"][0]
|
admin_user = raw_config["users"][0]
|
||||||
|
|
||||||
admin_user["access_key"] = access_key
|
if is_v2:
|
||||||
admin_user["secret_key"] = secret_key
|
admin_keys = admin_user.get("access_keys", [])
|
||||||
else:
|
if admin_keys:
|
||||||
raw_config = {
|
admin_keys[0]["access_key"] = access_key
|
||||||
"users": [
|
admin_keys[0]["secret_key"] = secret_key
|
||||||
{
|
else:
|
||||||
|
from datetime import datetime as _dt, timezone as _tz
|
||||||
|
admin_user["access_keys"] = [{
|
||||||
"access_key": access_key,
|
"access_key": access_key,
|
||||||
"secret_key": secret_key,
|
"secret_key": secret_key,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": _dt.now(_tz.utc).isoformat(),
|
||||||
|
}]
|
||||||
|
else:
|
||||||
|
admin_user["access_key"] = access_key
|
||||||
|
admin_user["secret_key"] = secret_key
|
||||||
|
else:
|
||||||
|
from datetime import datetime as _dt, timezone as _tz
|
||||||
|
raw_config = {
|
||||||
|
"version": 2,
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"user_id": f"u-{secrets.token_hex(8)}",
|
||||||
"display_name": "Local Admin",
|
"display_name": "Local Admin",
|
||||||
|
"enabled": True,
|
||||||
|
"access_keys": [
|
||||||
|
{
|
||||||
|
"access_key": access_key,
|
||||||
|
"secret_key": secret_key,
|
||||||
|
"status": "active",
|
||||||
|
"created_at": _dt.now(_tz.utc).isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
"policies": [
|
"policies": [
|
||||||
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
{"bucket": "*", "actions": list(ALLOWED_ACTIONS)}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,12 +17,20 @@ window.IAMManagement = (function() {
|
|||||||
var currentDeleteKey = null;
|
var currentDeleteKey = null;
|
||||||
var currentExpiryKey = null;
|
var currentExpiryKey = null;
|
||||||
|
|
||||||
var ALL_S3_ACTIONS = ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors'];
|
var ALL_S3_ACTIONS = [
|
||||||
|
'list', 'read', 'write', 'delete', 'share', 'policy',
|
||||||
|
'replication', 'lifecycle', 'cors',
|
||||||
|
'create_bucket', 'delete_bucket',
|
||||||
|
'versioning', 'tagging', 'encryption', 'quota',
|
||||||
|
'object_lock', 'notification', 'logging', 'website'
|
||||||
|
];
|
||||||
|
|
||||||
var policyTemplates = {
|
var policyTemplates = {
|
||||||
full: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'replication', 'lifecycle', 'cors', 'iam:*'] }],
|
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:*'] }],
|
||||||
readonly: [{ bucket: '*', actions: ['list', 'read'] }],
|
readonly: [{ bucket: '*', actions: ['list', 'read'] }],
|
||||||
writer: [{ bucket: '*', actions: ['list', 'read', 'write'] }]
|
writer: [{ bucket: '*', actions: ['list', 'read', 'write'] }],
|
||||||
|
operator: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'create_bucket', 'delete_bucket'] }],
|
||||||
|
bucketadmin: [{ bucket: '*', actions: ['list', 'read', 'write', 'delete', 'share', 'policy', 'create_bucket', 'delete_bucket', 'versioning', 'tagging', 'encryption', 'cors', 'lifecycle', 'quota', 'object_lock', 'notification', 'logging', 'website', 'replication'] }]
|
||||||
};
|
};
|
||||||
|
|
||||||
function isAdminUser(policies) {
|
function isAdminUser(policies) {
|
||||||
|
|||||||
@@ -235,7 +235,7 @@
|
|||||||
{% set bucket_label = 'All Buckets' if policy.bucket == '*' else policy.bucket %}
|
{% set bucket_label = 'All Buckets' if policy.bucket == '*' else policy.bucket %}
|
||||||
{% if '*' in policy.actions %}
|
{% if '*' in policy.actions %}
|
||||||
{% set perm_label = 'Full Access' %}
|
{% set perm_label = 'Full Access' %}
|
||||||
{% elif policy.actions|length >= 9 %}
|
{% elif policy.actions|length >= 19 %}
|
||||||
{% set perm_label = 'Full Access' %}
|
{% set perm_label = 'Full Access' %}
|
||||||
{% elif 'list' in policy.actions and 'read' in policy.actions and 'write' in policy.actions and 'delete' in policy.actions %}
|
{% elif 'list' in policy.actions and 'read' in policy.actions and 'write' in policy.actions and 'delete' in policy.actions %}
|
||||||
{% set perm_label = 'Read + Write + Delete' %}
|
{% set perm_label = 'Read + Write + Delete' %}
|
||||||
@@ -354,6 +354,8 @@
|
|||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="full">Full Control</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="full">Full Control</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="readonly">Read-Only</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="readonly">Read-Only</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="writer">Read + Write</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="writer">Read + Write</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="operator">Operator</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-create-policy-template="bucketadmin">Bucket Admin</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -404,6 +406,8 @@
|
|||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="full">Full Control</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="full">Full Control</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="readonly">Read-Only</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="readonly">Read-Only</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="writer">Read + Write</button>
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="writer">Read + Write</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="operator">Operator</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" type="button" data-policy-template="bucketadmin">Bucket Admin</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ def app(tmp_path: Path):
|
|||||||
"access_key": "test",
|
"access_key": "test",
|
||||||
"secret_key": "secret",
|
"secret_key": "secret",
|
||||||
"display_name": "Test User",
|
"display_name": "Test User",
|
||||||
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy"]}],
|
"policies": [{"bucket": "*", "actions": ["list", "read", "write", "delete", "policy",
|
||||||
|
"create_bucket", "delete_bucket", "share", "versioning", "tagging",
|
||||||
|
"encryption", "cors", "lifecycle", "replication", "quota",
|
||||||
|
"object_lock", "notification", "logging", "website"]}],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user