Files
app/backend/shared/persimmons.py
T

595 lines
24 KiB
Python

from __future__ import annotations
import json
import re
import secrets
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
LEGACY_REPO = "matcha/persimmons"
META_PREFIX = "PERSIM_META_"
SECRET_PREFIX = "PERSIM_SECRET_"
JUICE_PREFIX = "PERSIM_JUICE_"
ID_RE = re.compile(r"[A-F0-9]{12}")
REPO_RE = re.compile(r"[A-Za-z0-9_.-]{1,100}/[A-Za-z0-9_.-]{1,100}")
SECRET_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
LEGACY_ID_PREFIX = "secret:"
GiteaRequest = Callable[..., Awaitable[Any]]
def utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def connection_id(value: str) -> str:
value = str(value or "").strip().upper()
if not ID_RE.fullmatch(value):
raise ValueError("invalid connection id")
return value
def secret_name(value: str) -> str:
value = re.sub(r"[^A-Za-z0-9_]+", "_", str(value or "").strip().upper()).strip("_")
if value and value[0].isdigit():
value = f"P_{value}"
value = value[:100]
if not value or not SECRET_NAME_RE.fullmatch(value):
raise ValueError("valid secret name is required")
return value
def metadata_secret_name(metadata: dict[str, Any], cid: str) -> str:
stored = str(metadata.get("secret_name") or "").strip()
if stored:
return secret_name(stored)
return f"{SECRET_PREFIX}{connection_id(cid)}"
def repository_name(value: str) -> str:
value = str(value or "").strip()
if not REPO_RE.fullmatch(value):
raise ValueError("invalid repository")
return value
def _items(data: Any, key: str) -> list[dict[str, Any]]:
if isinstance(data, list):
return [item for item in data if isinstance(item, dict)]
if isinstance(data, dict) and isinstance(data.get(key), list):
return [item for item in data[key] if isinstance(item, dict)]
return []
def _scopes(value: Any) -> list[str]:
raw = value.split(",") if isinstance(value, str) else value if isinstance(value, list) else []
result = [str(item).strip() for item in raw if str(item).strip()]
if len(result) > 40 or any(len(item) > 100 for item in result):
raise ValueError("invalid scopes")
return result
def vault_from_input(data: dict[str, Any]) -> dict[str, Any]:
raw_kind = str(data.get("vault_kind") or "").strip().lower()
if not raw_kind:
return {"kind": "repository", "repository": LEGACY_REPO, "implicit_legacy": True}
if raw_kind == "user":
return {"kind": "user"}
if raw_kind == "repository":
return {"kind": "repository", "repository": repository_name(str(data.get("repository") or ""))}
raise ValueError("invalid vault")
def clean_vault(vault: dict[str, Any]) -> dict[str, Any]:
if vault["kind"] == "user":
return {"kind": "user"}
return {"kind": "repository", "repository": vault["repository"]}
def metadata_from_input(data: dict[str, Any], *, vault: dict[str, Any], created_at: str | None = None) -> dict[str, Any]:
provider = str(data.get("provider") or "").strip()
credential_type = str(data.get("credential_type") or "").strip()
account = str(data.get("account") or "").strip()
name = str(data.get("name") or "").strip() or provider
notes = str(data.get("notes") or "").strip()
expires = str(data.get("expires") or "").strip() or None
if not provider or not credential_type:
raise ValueError("provider and credential type are required")
for value in (name, provider, account, credential_type):
if len(value) > 200:
raise ValueError("field too long")
if len(notes) > 1000:
raise ValueError("notes too long")
now = utc_now()
return {
"kind": "credential",
"name": name,
"provider": provider,
"account": account,
"credential_type": credential_type,
"scopes": _scopes(data.get("scopes")),
"created_at": created_at or now,
"updated_at": now,
"expires": expires,
"notes": notes,
"status": "active",
"vault": clean_vault(vault),
}
def _metadata(item: dict[str, Any]) -> dict[str, Any]:
raw = item.get("data") if item.get("data") is not None else item.get("value")
try:
value = json.loads(str(raw or "{}"))
except json.JSONDecodeError as exc:
raise ValueError("invalid Persimmons metadata") from exc
if not isinstance(value, dict):
raise ValueError("invalid Persimmons metadata")
return value
def metadata_vault(metadata: dict[str, Any], fallback_repository: str | None = None) -> dict[str, Any]:
vault = metadata.get("vault")
if isinstance(vault, dict) and vault.get("kind") == "user":
return {"kind": "user"}
if isinstance(vault, dict) and vault.get("kind") == "repository":
return {"kind": "repository", "repository": repository_name(str(vault.get("repository") or ""))}
return {"kind": "repository", "repository": fallback_repository or LEGACY_REPO}
def public_connection(cid: str, metadata: dict[str, Any], *, available: bool, fallback_repository: str | None = None) -> dict[str, Any]:
vault = metadata_vault(metadata, fallback_repository)
stored_name = metadata_secret_name(metadata, cid)
display_name = str(metadata.get("name") or metadata.get("provider") or stored_name)
return {
"id": cid,
"kind": str(metadata.get("kind") or "credential"),
"name": display_name,
"secret_name": stored_name,
"provider": str(metadata.get("provider") or ""),
"account": str(metadata.get("account") or ""),
"credential_type": str(metadata.get("credential_type") or ""),
"scopes": metadata.get("scopes") if isinstance(metadata.get("scopes"), list) else [],
"created_at": metadata.get("created_at"),
"updated_at": metadata.get("updated_at"),
"expires": metadata.get("expires") or None,
"notes": str(metadata.get("notes") or ""),
"status": "active" if available else "revoked",
"available": bool(available),
"vault_kind": vault["kind"],
"repository": vault.get("repository"),
}
def legacy_secret_name(value: str) -> str | None:
raw = str(value or "")
if not raw.startswith(LEGACY_ID_PREFIX):
return None
name = raw[len(LEGACY_ID_PREFIX):]
if not SECRET_NAME_RE.fullmatch(name):
raise ValueError("invalid legacy secret")
return name
def public_legacy_secret(
name: str,
vault: dict[str, Any],
*,
available: bool = True,
created_at: str | None = None,
description: str = "",
) -> dict[str, Any]:
if not SECRET_NAME_RE.fullmatch(name):
raise ValueError("invalid legacy secret")
return {
"id": f"{LEGACY_ID_PREFIX}{name}",
"kind": "credential",
"name": name,
"provider": "",
"account": "",
"credential_type": "Existing Gitea secret",
"scopes": [],
"created_at": created_at,
"updated_at": created_at,
"expires": None,
"notes": description,
"status": "active" if available else "revoked",
"available": bool(available),
"vault_kind": vault["kind"],
"repository": vault.get("repository"),
"legacy": True,
"secret_name": name,
}
def _prefix(vault: dict[str, Any]) -> str:
return "user/actions" if vault["kind"] == "user" else f"repos/{vault['repository']}/actions"
async def _validate_repository(request: GiteaRequest, repository: str) -> dict[str, Any]:
repo = await request("GET", f"repos/{repository}")
if not isinstance(repo, dict):
raise ValueError("repository unavailable")
permissions = repo.get("permissions") if isinstance(repo.get("permissions"), dict) else {}
if permissions and not (permissions.get("admin") or permissions.get("push")):
raise ValueError("repository is not writable")
return repo
async def list_repositories(request: GiteaRequest) -> dict[str, Any]:
repos: list[dict[str, Any]] = []
for page in range(1, 6):
data = await request("GET", "user/repos", params={"limit": 100, "page": page})
batch = _items(data, "data")
if not batch:
break
for repo in batch:
full_name = str(repo.get("full_name") or "")
permissions = repo.get("permissions") if isinstance(repo.get("permissions"), dict) else {}
if full_name and (not permissions or permissions.get("admin") or permissions.get("push")):
repos.append({
"full_name": full_name,
"private": bool(repo.get("private")),
"owner": str((repo.get("owner") or {}).get("login") or ""),
})
if len(batch) < 100:
break
repos.sort(key=lambda item: item["full_name"].lower())
return {"repositories": repos}
async def _variable_get(request: GiteaRequest, vault: dict[str, Any], cid: str, *, prefix: str = META_PREFIX) -> dict[str, Any] | None:
try:
return await request("GET", f"{_prefix(vault)}/variables/{prefix}{cid}")
except RuntimeError as exc:
if "HTTP 404" in str(exc):
return None
raise
async def _variable_put(request: GiteaRequest, vault: dict[str, Any], cid: str, metadata: dict[str, Any], *, exists: bool, prefix: str = META_PREFIX) -> None:
name = f"{prefix}{cid}"
path = f"{_prefix(vault)}/variables/{name}"
body = {
"value": json.dumps(metadata, separators=(",", ":"), ensure_ascii=False),
"description": "Persimmons object metadata. Contains no protected value.",
}
await request("PUT" if exists else "POST", path, json=body)
async def _list_vault(request: GiteaRequest, vault: dict[str, Any]) -> list[dict[str, Any]]:
if vault["kind"] == "repository" and vault["repository"] != LEGACY_REPO:
await _validate_repository(request, vault["repository"])
variable_data = await request("GET", f"{_prefix(vault)}/variables", params={"limit": 100})
secret_items: list[dict[str, Any]] = []
secret_listing_supported = True
try:
secret_data = await request("GET", f"{_prefix(vault)}/secrets", params={"limit": 100})
secret_items = _items(secret_data, "secrets")
except RuntimeError as exc:
if vault["kind"] == "user" and "HTTP 404" in str(exc):
secret_listing_supported = False
else:
raise
secret_names = {
str(item.get("name") or "")
for item in secret_items
if str(item.get("name") or "")
}
result: list[dict[str, Any]] = []
metadata_secret_names: set[str] = set()
for item in _items(variable_data, "variables"):
name = str(item.get("name") or "")
if not name.startswith(META_PREFIX):
continue
cid = name[len(META_PREFIX):]
if not ID_RE.fullmatch(cid):
continue
try:
metadata = _metadata(item)
except ValueError:
continue
stored_name = metadata_secret_name(metadata, cid)
metadata_secret_names.add(stored_name)
available = (
stored_name in secret_names
if secret_listing_supported
else str(metadata.get("status") or "active") == "active"
)
result.append(
public_connection(
cid,
metadata,
available=available,
fallback_repository=vault.get("repository"),
)
)
for item in secret_items:
name = str(item.get("name") or "")
if not name or name in metadata_secret_names or not SECRET_NAME_RE.fullmatch(name):
continue
result.append(
public_legacy_secret(
name,
clean_vault(vault),
available=True,
created_at=str(item.get("created_at") or "") or None,
description=str(item.get("description") or ""),
)
)
return result
async def list_connections(
request: GiteaRequest,
*,
repository: str | None = None,
include_legacy: bool = True,
include_user: bool = True,
) -> dict[str, Any]:
result: list[dict[str, Any]] = []
if include_user:
result.extend(await _list_vault(request, {"kind": "user"}))
target = repository_name(repository) if repository else None
if target:
result.extend(await _list_vault(request, {"kind": "repository", "repository": target}))
elif include_legacy:
try:
result.extend(await _list_vault(request, {"kind": "repository", "repository": LEGACY_REPO}))
except (RuntimeError, ValueError):
pass
result.sort(key=lambda item: str(item.get("updated_at") or item.get("created_at") or ""), reverse=True)
return {"connections": result}
async def create_connection(request: GiteaRequest, data: dict[str, Any]) -> dict[str, Any]:
credential = str(data.get("credential") or "")
if not credential or len(credential) > 16384:
raise ValueError("valid credential value is required")
vault = vault_from_input(data)
if vault["kind"] == "repository" and not vault.get("implicit_legacy"):
await _validate_repository(request, vault["repository"])
cid = secrets.token_hex(6).upper()
metadata = metadata_from_input(data, vault=vault)
stored_name = secret_name(metadata["name"])
metadata["name"] = stored_name
metadata["secret_name"] = stored_name
existing_data = await request("GET", f"{_prefix(vault)}/secrets", params={"limit": 100})
existing_names = {
str(item.get("name") or "")
for item in _items(existing_data, "secrets")
if str(item.get("name") or "")
}
if stored_name in existing_names:
raise ValueError(f"secret {stored_name} already exists")
secret_path = f"{_prefix(vault)}/secrets/{stored_name}"
await request("PUT", secret_path, json={"data": credential, "description": "Persimmons write-only credential"})
try:
await _variable_put(request, vault, cid, metadata, exists=False)
except Exception:
try:
await request("DELETE", secret_path)
except Exception:
pass
raise
return public_connection(cid, metadata, available=True)
async def _existing(request: GiteaRequest, cid: str, vault: dict[str, Any]) -> dict[str, Any]:
if vault["kind"] == "repository" and not vault.get("implicit_legacy"):
await _validate_repository(request, vault["repository"])
variable = await _variable_get(request, vault, cid)
if variable is None:
raise ValueError("connection not found")
metadata = _metadata(variable)
if metadata_vault(metadata, vault.get("repository")) != clean_vault(vault):
raise ValueError("connection vault does not match")
return metadata
async def rotate_connection(request: GiteaRequest, cid: str, credential: str, vault_data: dict[str, Any] | None = None) -> dict[str, Any]:
credential = str(credential or "")
if not credential or len(credential) > 16384:
raise ValueError("valid credential value is required")
vault = vault_from_input(vault_data or {})
legacy_name = legacy_secret_name(cid)
if legacy_name:
if vault["kind"] == "repository" and not vault.get("implicit_legacy"):
await _validate_repository(request, vault["repository"])
await request(
"PUT",
f"{_prefix(vault)}/secrets/{legacy_name}",
json={"data": credential, "description": "Existing Gitea secret managed by Persimmons"},
)
return public_legacy_secret(legacy_name, clean_vault(vault), available=True)
cid = connection_id(cid)
metadata = await _existing(request, cid, vault)
stored_name = metadata_secret_name(metadata, cid)
await request("PUT", f"{_prefix(vault)}/secrets/{stored_name}", json={"data": credential, "description": "Persimmons write-only credential"})
metadata["updated_at"] = utc_now()
metadata["status"] = "active"
await _variable_put(request, vault, cid, metadata, exists=True)
return public_connection(cid, metadata, available=True)
async def revoke_connection(request: GiteaRequest, cid: str, vault_data: dict[str, Any] | None = None) -> dict[str, Any]:
vault = vault_from_input(vault_data or {})
legacy_name = legacy_secret_name(cid)
if legacy_name:
if vault["kind"] == "repository" and not vault.get("implicit_legacy"):
await _validate_repository(request, vault["repository"])
try:
await request("DELETE", f"{_prefix(vault)}/secrets/{legacy_name}")
except RuntimeError as exc:
if "HTTP 404" not in str(exc):
raise
return public_legacy_secret(legacy_name, clean_vault(vault), available=False)
cid = connection_id(cid)
metadata = await _existing(request, cid, vault)
stored_name = metadata_secret_name(metadata, cid)
try:
await request("DELETE", f"{_prefix(vault)}/secrets/{stored_name}")
except RuntimeError as exc:
if "HTTP 404" not in str(exc):
raise
metadata["updated_at"] = utc_now()
metadata["status"] = "revoked"
await _variable_put(request, vault, cid, metadata, exists=True)
return public_connection(cid, metadata, available=False)
async def delete_connection(request: GiteaRequest, cid: str, vault_data: dict[str, Any] | None = None) -> dict[str, Any]:
vault = vault_from_input(vault_data or {})
legacy_name = legacy_secret_name(cid)
if legacy_name:
if vault["kind"] == "repository" and not vault.get("implicit_legacy"):
await _validate_repository(request, vault["repository"])
try:
await request("DELETE", f"{_prefix(vault)}/secrets/{legacy_name}")
except RuntimeError as exc:
if "HTTP 404" not in str(exc):
raise
return {
"id": cid,
"deleted": True,
"vault_kind": vault["kind"],
"repository": vault.get("repository"),
"legacy": True,
}
cid = connection_id(cid)
metadata = await _existing(request, cid, vault)
stored_name = metadata_secret_name(metadata, cid)
for path in (
f"{_prefix(vault)}/secrets/{stored_name}",
f"{_prefix(vault)}/variables/{META_PREFIX}{cid}",
):
try:
await request("DELETE", path)
except RuntimeError as exc:
if "HTTP 404" not in str(exc):
raise
return {"id": cid, "deleted": True, "vault_kind": vault["kind"], "repository": vault.get("repository")}
def juice_vault(data: dict[str, Any]) -> dict[str, Any]:
"""Juice defaults to My vault, never the implicit legacy repository."""
repository = data.get("repository")
kind = data.get("vault_kind") or ("repository" if repository else "user")
if kind == "user" and repository:
raise ValueError("Juice vault does not match repository")
return vault_from_input({"vault_kind": kind, "repository": repository})
def _juice_policy(data: dict[str, Any]) -> dict[str, Any]:
subject = data.get("subject")
scopes = data.get("scopes")
if not isinstance(subject, str) or not subject.strip() or len(subject) > 200:
raise ValueError("valid subject is required (maximum 200 characters)")
if not isinstance(scopes, list) or not scopes or any(
not isinstance(scope, str) or not scope.strip() for scope in scopes
):
raise ValueError("nonempty scopes array is required")
expires = data.get("expires_at")
if expires is not None:
try:
if not isinstance(expires, str) or len(expires) > 40:
raise ValueError
parsed = datetime.fromisoformat(expires.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError
expires = parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
except ValueError as exc:
raise ValueError("expires_at must be a timestamp with timezone or null") from exc
return {"subject": subject.strip(), "scopes": _scopes(scopes), "expires_at": expires}
def public_juice(jid: str, metadata: dict[str, Any], vault: dict[str, Any]) -> dict[str, Any]:
if metadata.get("vault") != clean_vault(vault):
raise ValueError("Juice vault does not match")
if metadata.get("kind") != "juice" or metadata.get("status") not in {"active", "revoked"}:
raise ValueError("invalid Juice metadata")
source = metadata.get("source")
if not isinstance(source, str) or not source or len(source) > 200:
raise ValueError("invalid Juice source")
# Explicit projection: never return arbitrary stored fields or source metadata.
return {
"id": jid, "kind": "juice",
"source_id": connection_id(metadata.get("source_id")), "source": source,
**_juice_policy(metadata), "created_at": metadata.get("created_at"),
"status": metadata["status"], "revocable": True,
"vault": clean_vault(vault), "vault_kind": vault["kind"],
"repository": vault.get("repository"),
}
async def create_juice(request: GiteaRequest, data: dict[str, Any]) -> dict[str, Any]:
vault = juice_vault(data)
cid = connection_id(data.get("source_id"))
policy = _juice_policy(data)
if policy["expires_at"] is not None and datetime.fromisoformat(
policy["expires_at"].replace("Z", "+00:00")
) <= datetime.now(timezone.utc):
raise ValueError("expires_at must be in the future")
source = await _existing(request, cid, vault)
if source.get("status", "active") != "active":
raise ValueError("source connection is revoked")
# Gitea's collection returns names only; no individual secret GET is allowed.
try:
names = await request("GET", f"{_prefix(vault)}/secrets", params={"limit": 100})
if metadata_secret_name(source, cid) not in {item.get("name") for item in _items(names, "secrets")}:
raise ValueError("source connection is unavailable")
except RuntimeError as exc:
# Older Gitea user APIs lack secret listing; use the saved active metadata.
if vault["kind"] != "user" or "HTTP 404" not in str(exc):
raise
jid = secrets.token_hex(6).upper()
metadata = public_juice(jid, {
"kind": "juice", "source_id": cid, "source": source.get("provider"),
**policy, "created_at": utc_now(), "status": "active", "vault": clean_vault(vault),
}, vault)
await _variable_put(request, vault, jid, metadata, exists=False, prefix=JUICE_PREFIX)
return metadata
async def list_juice(
request: GiteaRequest, *, repository: str | None = None, source_id: str | None = None,
) -> dict[str, Any]:
vault = juice_vault({"repository": repository})
cid = connection_id(source_id) if source_id is not None else None
if vault["kind"] == "repository":
await _validate_repository(request, vault["repository"])
variables = await request("GET", f"{_prefix(vault)}/variables", params={"limit": 100})
result = []
for item in _items(variables, "variables"):
name = str(item.get("name") or "")
if not name.startswith(JUICE_PREFIX) or not ID_RE.fullmatch(name[len(JUICE_PREFIX):]):
continue
try:
juice = public_juice(name[len(JUICE_PREFIX):], _metadata(item), vault)
except ValueError:
continue
if cid is None or juice["source_id"] == cid:
result.append(juice)
result.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
return {"juice": result}
async def revoke_juice(
request: GiteaRequest, jid: str, vault_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
jid = connection_id(jid)
vault = juice_vault(vault_data or {})
if vault["kind"] == "repository":
await _validate_repository(request, vault["repository"])
variable = await _variable_get(request, vault, jid, prefix=JUICE_PREFIX)
if variable is None:
raise ValueError("Juice not found")
metadata = public_juice(jid, _metadata(variable), vault)
metadata["status"] = "revoked"
await _variable_put(request, vault, jid, metadata, exists=True, prefix=JUICE_PREFIX)
return metadata