Initial curated Unraid-AppOps-Gateway source from 3bd6f6fee310125bfbb4d4650389a817f498724d
This commit is contained in:
commit
1dfbca08a1
528 files changed
+131054
No files matched your search
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..core.config import settings
|
||||
from ..core.redaction import redact_message
|
||||
from ..core.time import utc_now
|
||||
from ..db.models import Secret
|
||||
|
||||
|
||||
SECRET_LIKE_PATTERN = re.compile(r"(secret|token|api[_-]?key|password|x-api-key)", re.IGNORECASE)
|
||||
REDACTED = "[REDACTED]"
|
||||
|
||||
|
||||
def _derive_fernet_key(value: str) -> bytes:
|
||||
value = value.strip()
|
||||
try:
|
||||
Fernet(value.encode("utf-8"))
|
||||
return value.encode("utf-8")
|
||||
except (ValueError, TypeError):
|
||||
digest = hashlib.sha256(value.encode("utf-8")).digest()
|
||||
return base64.urlsafe_b64encode(digest)
|
||||
|
||||
|
||||
def _fallback_material() -> str:
|
||||
return f"appops-development-secret-store:{settings.db_path}:{settings.data_dir}"
|
||||
|
||||
|
||||
def _active_key_material() -> str:
|
||||
if settings.secret_key:
|
||||
if settings.environment == "production" and len(settings.secret_key) < 32:
|
||||
raise RuntimeError("APPOPS_SECRET_KEY is too weak for production")
|
||||
return settings.secret_key
|
||||
return _fallback_material()
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
return Fernet(_derive_fernet_key(_active_key_material()))
|
||||
|
||||
|
||||
def secret_storage_status() -> dict[str, Any]:
|
||||
key_source = settings.secret_key_source if settings.secret_key else "missing"
|
||||
encrypted = bool(settings.secret_key) or settings.environment != "production"
|
||||
key_strong = bool(settings.secret_key and len(settings.secret_key) >= 32)
|
||||
production_ready = key_strong and key_source in {"env", "file"}
|
||||
warnings: list[str] = []
|
||||
if settings.environment == "production" and not settings.secret_key:
|
||||
warnings.append("APPOPS_SECRET_KEY is required for production encrypted secret storage.")
|
||||
elif settings.environment == "production" and not key_strong:
|
||||
warnings.append("APPOPS_SECRET_KEY must contain at least 32 characters in production.")
|
||||
if settings.secret_key_source == "file_unreadable":
|
||||
warnings.append("APPOPS_SECRET_KEY_FILE could not be read.")
|
||||
if settings.environment != "production" and not settings.secret_key:
|
||||
key_source = "development_fallback"
|
||||
warnings.append("Development fallback key is active; set APPOPS_SECRET_KEY before production.")
|
||||
return {
|
||||
"encryptedAtRest": encrypted,
|
||||
"enabled": encrypted,
|
||||
"keySource": key_source,
|
||||
"rotationEnabled": settings.secret_rotation_enabled,
|
||||
"productionReady": production_ready,
|
||||
"status": "ready" if production_ready or settings.environment != "production" else "warning",
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def set_secret(
|
||||
session: Session,
|
||||
name: str,
|
||||
value: str,
|
||||
*,
|
||||
used_by: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
commit: bool = True,
|
||||
) -> Secret:
|
||||
clean_name = name.strip().upper()
|
||||
clean_value = value.strip()
|
||||
now = utc_now()
|
||||
encrypted = _fernet().encrypt(clean_value.encode("utf-8")).decode("utf-8")
|
||||
row = session.get(Secret, clean_name)
|
||||
if not row:
|
||||
row = Secret(id=clean_name, name=clean_name, encrypted_value=encrypted, created_at=now)
|
||||
row.name = clean_name
|
||||
row.encrypted_value = encrypted
|
||||
row.updated_at = now
|
||||
row.used_by_json = sorted(set(used_by or row.used_by_json or []))
|
||||
row.metadata_json = metadata or row.metadata_json or {}
|
||||
session.add(row)
|
||||
if commit:
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def get_secret(session: Session, name: str, *, touch: bool = True) -> str | None:
|
||||
row = session.get(Secret, name.strip().upper())
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
value = _fernet().decrypt(row.encrypted_value.encode("utf-8")).decode("utf-8")
|
||||
except InvalidToken:
|
||||
return None
|
||||
if touch:
|
||||
row.last_used_at = utc_now()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return value
|
||||
|
||||
|
||||
def has_secret(session: Session, name: str) -> bool:
|
||||
row = session.get(Secret, name.strip().upper())
|
||||
return bool(row and row.encrypted_value)
|
||||
|
||||
|
||||
def delete_secret(session: Session, name: str) -> None:
|
||||
row = session.get(Secret, name.strip().upper())
|
||||
if row:
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
|
||||
|
||||
def rotate_secret(session: Session, name: str, new_value: str, *, used_by: list[str] | None = None) -> Secret:
|
||||
row = session.get(Secret, name.strip().upper())
|
||||
return set_secret(session, name, new_value, used_by=used_by or (row.used_by_json if row else None))
|
||||
|
||||
|
||||
def secret_status(row: Secret) -> dict[str, Any]:
|
||||
updated_at = row.updated_at or row.created_at
|
||||
age_days = max(0, (utc_now() - updated_at).days) if updated_at else None
|
||||
return {
|
||||
"secretRef": row.id,
|
||||
"name": row.name,
|
||||
"hasSecret": bool(row.encrypted_value),
|
||||
"status": "configured" if row.encrypted_value else "missing",
|
||||
"usedBy": row.used_by_json or [],
|
||||
"lastUpdated": row.updated_at.isoformat() if row.updated_at else None,
|
||||
"lastUsed": row.last_used_at.isoformat() if row.last_used_at else None,
|
||||
"ageDays": age_days,
|
||||
"rotationRecommended": bool(age_days is not None and age_days >= 90),
|
||||
"metadata": {
|
||||
key: value
|
||||
for key, value in (row.metadata_json or {}).items()
|
||||
if not SECRET_LIKE_PATTERN.search(str(key))
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def empty_secret_status(name: str, *, used_by: list[str] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"secretRef": name.strip().upper(),
|
||||
"name": name.strip().upper(),
|
||||
"hasSecret": False,
|
||||
"status": "missing",
|
||||
"usedBy": used_by or [],
|
||||
"lastUpdated": None,
|
||||
"lastUsed": None,
|
||||
"ageDays": None,
|
||||
"rotationRecommended": False,
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
|
||||
def list_secret_status(session: Session) -> list[dict[str, Any]]:
|
||||
rows = session.exec(select(Secret).order_by(Secret.name)).all()
|
||||
return [secret_status(row) for row in rows]
|
||||
|
||||
|
||||
def redact_secret_value(value: str | None) -> str | None:
|
||||
return REDACTED if value else value
|
||||
|
||||
|
||||
def _known_secret_values(session: Session | None) -> list[str]:
|
||||
if session is None:
|
||||
return []
|
||||
values: list[str] = []
|
||||
rows = session.exec(select(Secret)).all()
|
||||
for row in rows:
|
||||
value = get_secret(session, row.id)
|
||||
if value:
|
||||
values.append(value)
|
||||
return values
|
||||
|
||||
|
||||
def redact_text(text: str, *, session: Session | None = None, known_values: list[str] | None = None) -> str:
|
||||
redacted = redact_message(text)
|
||||
redacted = re.sub(r"(x-api-key\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE)
|
||||
redacted = re.sub(r"(apiKey\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE)
|
||||
redacted = re.sub(r"(token\s*[:=]\s*)([^\s\"']+)", rf"\1{REDACTED}", redacted, flags=re.IGNORECASE)
|
||||
for value in [*(known_values or []), *_known_secret_values(session)]:
|
||||
if value:
|
||||
redacted = redacted.replace(value, REDACTED)
|
||||
return redacted
|
||||
Reference in new issue
Block a user