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
+51
View File
@@ -0,0 +1,51 @@
from app.core.auth.models import (
UserSession,
CapabilityTier,
LoginRequest,
SSOCallbackRequest,
SSOTokenVerifyRequest,
TokenResponse,
RBACSettingsSchema
)
from app.core.auth.security import (
verify_password,
hash_password,
create_session_token,
decode_session_token
)
from app.core.auth.role_resolver import (
resolve_user_capabilities,
load_rbac_settings,
save_rbac_settings
)
from app.core.auth.keycloak import keycloak_client
from app.core.auth.dependencies import (
get_current_user,
get_optional_current_user,
require_capabilities,
require_admin,
require_editor
)
__all__ = [
"UserSession",
"CapabilityTier",
"LoginRequest",
"SSOCallbackRequest",
"SSOTokenVerifyRequest",
"TokenResponse",
"RBACSettingsSchema",
"verify_password",
"hash_password",
"create_session_token",
"decode_session_token",
"resolve_user_capabilities",
"load_rbac_settings",
"save_rbac_settings",
"keycloak_client",
"get_current_user",
"get_optional_current_user",
"require_capabilities",
"require_admin",
"require_editor",
]
+82
View File
@@ -0,0 +1,82 @@
from typing import List, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.core.auth.models import UserSession
from app.core.auth.security import decode_session_token
security_scheme = HTTPBearer(auto_error=False)
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme)) -> UserSession:
"""Extract and validate current authenticated user from session token."""
if not credentials or not credentials.credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Otentikasi diperlukan. Silakan login terlebih dahulu.",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_session_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token sesi tidak valid atau telah kedaluwarsa. Silakan login kembali.",
headers={"WWW-Authenticate": "Bearer"},
)
user_data = payload.get("user")
if not user_data:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Struktur data sesi tidak valid.",
headers={"WWW-Authenticate": "Bearer"},
)
return UserSession(**user_data)
async def get_optional_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme)) -> Optional[UserSession]:
"""Extract current user if token is present, otherwise return None."""
if not credentials or not credentials.credentials:
return None
payload = decode_session_token(credentials.credentials)
if not payload or not payload.get("user"):
return None
try:
return UserSession(**payload["user"])
except Exception:
return None
def require_capabilities(allowed_caps: List[str]):
"""Enforce that the current user possesses one of the allowed capabilities."""
async def capability_checker(current_user: UserSession = Depends(get_current_user)) -> UserSession:
if current_user.capability not in allowed_caps and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Akses ditolak: Akun Anda memiliki peran '{current_user.capability}'. Tindakan ini memerlukan hak akses '{'/'.join(allowed_caps)}'."
)
return current_user
return capability_checker
# Shortcuts
async def require_admin(current_user: UserSession = Depends(get_current_user)) -> UserSession:
"""Enforce Administrator role (Full access + Settings control)."""
if not current_user.is_admin and current_user.capability != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Akses ditolak: Menu Pengaturan dan kontrol IAM hanya dapat diakses oleh Administrator / Superadmin."
)
return current_user
async def require_editor(current_user: UserSession = Depends(get_current_user)) -> UserSession:
"""Enforce Editor capability (Data Engineer / Full Canvas Access)."""
if not current_user.can_edit and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Akses ditolak: Akun Anda memiliki peran Read-Only (Pimpinan/Auditor). Perubahan atau deployment DAG hanya dapat dilakukan oleh Data Engineer."
)
return current_user
+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()
+57
View File
@@ -0,0 +1,57 @@
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from enum import Enum
class CapabilityTier(str, Enum):
ADMIN = "admin"
EDITOR = "editor"
VIEWER = "viewer"
class UserSession(BaseModel):
id: str
username: str
name: str
email: Optional[str] = None
auth_provider: str = "local" # "local" or "keycloak"
raw_roles: List[str] = Field(default_factory=list)
raw_groups: List[str] = Field(default_factory=list)
capability: str = "viewer" # "admin", "editor", "viewer"
can_edit: bool = False
can_deploy: bool = False
is_admin: bool = False
is_readonly: bool = True
class LoginRequest(BaseModel):
username: str
password: str
class SSOCallbackRequest(BaseModel):
code: str
redirect_uri: str
code_verifier: Optional[str] = None
class SSOTokenVerifyRequest(BaseModel):
access_token: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "Bearer"
expires_in: int = 86400
user: UserSession
class RBACSettingsSchema(BaseModel):
editor_roles: List[str] = Field(default_factory=list)
editor_groups: List[str] = Field(default_factory=list)
viewer_roles: List[str] = Field(default_factory=list)
viewer_groups: List[str] = Field(default_factory=list)
admin_roles: List[str] = Field(default_factory=list)
admin_groups: List[str] = Field(default_factory=list)
updated_at: Optional[str] = None
updated_by: Optional[str] = None
+88
View File
@@ -0,0 +1,88 @@
import os
import json
import logging
from datetime import datetime, timezone
from typing import List, Dict, Any, Tuple
from app.core.config import settings
from app.core.auth.models import RBACSettingsSchema, CapabilityTier
logger = logging.getLogger(__name__)
# Cache for loaded settings
_SETTINGS_CACHE: Dict[str, Any] = {}
def get_rbac_storage_path() -> str:
"""Resolve storage path for dynamic RBAC settings file."""
base_dir = settings.METADATA_DIR
if not os.path.exists(base_dir):
base_dir = settings.CONFIGS_DIR
return os.path.join(base_dir, "rbac_mappings.json")
def load_rbac_settings() -> RBACSettingsSchema:
"""Load dynamic RBAC settings from persistent storage, fallback to config.py defaults."""
filepath = get_rbac_storage_path()
if os.path.exists(filepath):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return RBACSettingsSchema(**data)
except Exception as e:
logger.warning(f"Failed to read custom RBAC settings from {filepath}: {e}. Using defaults.")
# Fallback to defaults defined in config.py
return RBACSettingsSchema(
editor_roles=list(settings.AUTH_EDITOR_ROLES),
editor_groups=list(settings.AUTH_EDITOR_GROUPS),
viewer_roles=list(settings.AUTH_VIEWER_ROLES),
viewer_groups=list(settings.AUTH_VIEWER_GROUPS),
admin_roles=list(settings.AUTH_ADMIN_ROLES),
admin_groups=list(settings.AUTH_ADMIN_GROUPS),
updated_at=datetime.now(timezone.utc).isoformat(),
updated_by="system_default"
)
def save_rbac_settings(schema: RBACSettingsSchema, author: str = "admin") -> RBACSettingsSchema:
"""Save updated dynamic RBAC settings to persistent storage."""
filepath = get_rbac_storage_path()
os.makedirs(os.path.dirname(filepath), exist_ok=True)
schema.updated_at = datetime.now(timezone.utc).isoformat()
schema.updated_by = author
with open(filepath, "w", encoding="utf-8") as f:
json.dump(schema.model_dump(), f, indent=2)
logger.info(f"Dynamic RBAC settings successfully updated by {author} at {filepath}")
return schema
def resolve_user_capabilities(user_roles: List[str], user_groups: List[str]) -> Tuple[str, bool, bool, bool, bool]:
"""
Resolve raw user roles & groups into internal capabilities:
Returns (capability, can_edit, can_deploy, is_admin, is_readonly)
"""
rbac = load_rbac_settings()
roles_set = {str(r).strip().lower() for r in user_roles if r}
groups_set = {str(g).strip().lower() for g in user_groups if g}
admin_roles_set = {str(r).strip().lower() for r in rbac.admin_roles}
admin_groups_set = {str(g).strip().lower() for g in rbac.admin_groups}
editor_roles_set = {str(r).strip().lower() for r in rbac.editor_roles}
editor_groups_set = {str(g).strip().lower() for g in rbac.editor_groups}
# 1. Check Admin Capability (Highest Priority)
if roles_set.intersection(admin_roles_set) or groups_set.intersection(admin_groups_set):
return CapabilityTier.ADMIN.value, True, True, True, False
# 2. Check Editor Capability (Data Engineer - Full Canvas Access)
if roles_set.intersection(editor_roles_set) or groups_set.intersection(editor_groups_set):
return CapabilityTier.EDITOR.value, True, True, False, False
# 3. Default Safe Fallback: Viewer (Pimpinan / Auditor / Read-Only)
return CapabilityTier.VIEWER.value, False, False, False, True
+52
View File
@@ -0,0 +1,52 @@
import hmac
import hashlib
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any
import jwt
from app.core.config import settings
def verify_password(plain_password: str, stored_password: str) -> bool:
"""Constant-time password comparison to prevent timing attacks."""
if not plain_password or not stored_password:
return False
# Supports direct string comparison via hmac or sha256 hash
if stored_password.startswith("sha256$"):
_, salt, expected_hash = stored_password.split("$", 2)
calc_hash = hashlib.sha256((salt + plain_password).encode("utf-8")).hexdigest()
return hmac.compare_digest(calc_hash, expected_hash)
return hmac.compare_digest(plain_password, stored_password)
def hash_password(plain_password: str, salt: str = "merica_dlh_salt") -> str:
"""Generate salted sha256 password hash."""
calc_hash = hashlib.sha256((salt + plain_password).encode("utf-8")).hexdigest()
return f"sha256${salt}${calc_hash}"
def create_session_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Create a unified session JWT token signed with SECRET_KEY."""
to_encode = data.copy()
now = datetime.now(timezone.utc)
if expires_delta:
expire = now + expires_delta
else:
expire = now + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({
"exp": int(expire.timestamp()),
"iat": int(now.timestamp())
})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def decode_session_token(token: str) -> Optional[Dict[str, Any]]:
"""Decode and validate a session JWT token."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except Exception:
return None