feat(appliance): initial release of MERICA standalone appliance v1.0.0

This commit is contained in:
2026-10-21 17:16:46 +07:00
commit 851be0cda7
144 changed files with 31135 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
import json
import logging
import base64
from typing import Dict, Any, Optional, Tuple, List
import requests
from app.core.config import settings
from app.core.auth.models import UserSession
from app.core.auth.role_resolver import resolve_user_capabilities
logger = logging.getLogger(__name__)
class KeycloakClient:
def __init__(self):
self.base_url = settings.KEYCLOAK_INTERNAL_URL.rstrip("/")
self.realm = settings.KEYCLOAK_REALM
self.client_id = settings.KEYCLOAK_CLIENT_ID
@property
def realm_url(self) -> str:
return f"{self.base_url}/realms/{self.realm}"
@property
def token_endpoint(self) -> str:
return f"{self.realm_url}/protocol/openid-connect/token"
@property
def userinfo_endpoint(self) -> str:
return f"{self.realm_url}/protocol/openid-connect/userinfo"
@property
def jwks_endpoint(self) -> str:
return f"{self.realm_url}/protocol/openid-connect/certs"
def exchange_code(self, code: str, redirect_uri: str, code_verifier: Optional[str] = None) -> Dict[str, Any]:
"""Exchange authorization code for tokens from Keycloak."""
data = {
"grant_type": "authorization_code",
"client_id": self.client_id,
"code": code,
"redirect_uri": redirect_uri,
}
if code_verifier:
data["code_verifier"] = code_verifier
resp = requests.post(self.token_endpoint, data=data, timeout=10)
if not resp.ok:
logger.error(f"Keycloak code exchange failed: {resp.status_code} - {resp.text}")
raise ValueError(f"Keycloak token exchange error: {resp.text}")
return resp.json()
def parse_token_claims(self, access_token: str) -> Dict[str, Any]:
"""Parse unverified payload claims from Keycloak JWT token."""
try:
parts = access_token.split(".")
if len(parts) != 3:
raise ValueError("Invalid JWT format")
payload_b64 = parts[1]
# Add padding
rem = len(payload_b64) % 4
if rem > 0:
payload_b64 += "=" * (4 - rem)
payload_json = base64.urlsafe_b64decode(payload_b64).decode("utf-8")
return json.loads(payload_json)
except Exception as e:
logger.error(f"Failed to parse Keycloak access token: {e}")
raise ValueError(f"Invalid access token: {e}")
def build_user_session_from_token(self, token_data: Dict[str, Any]) -> UserSession:
"""Construct unified UserSession from Keycloak access_token and id_token."""
access_token = token_data.get("access_token", "")
claims = self.parse_token_claims(access_token)
sub = claims.get("sub", "")
username = claims.get("preferred_username") or claims.get("username") or sub
name = claims.get("name") or username
email = claims.get("email")
# Extract roles from realm_access or direct roles claim
raw_roles: List[str] = []
if isinstance(claims.get("roles"), list):
raw_roles.extend(claims["roles"])
realm_access = claims.get("realm_access", {})
if isinstance(realm_access.get("roles"), list):
raw_roles.extend(realm_access["roles"])
# Extract groups
raw_groups: List[str] = claims.get("groups", [])
if not isinstance(raw_groups, list):
raw_groups = []
# Resolve internal capabilities dynamically
cap, can_edit, can_deploy, is_admin, is_readonly = resolve_user_capabilities(raw_roles, raw_groups)
return UserSession(
id=sub,
username=username,
name=name,
email=email,
auth_provider="keycloak",
raw_roles=raw_roles,
raw_groups=raw_groups,
capability=cap,
can_edit=can_edit,
can_deploy=can_deploy,
is_admin=is_admin,
is_readonly=is_readonly
)
keycloak_client = KeycloakClient()