feat: login time window rules (EPIC_000014)
- Implemented RuleManager and JSON storage - Added background enforcement scheduler - Added Web UI for rule management at /ui/rules - Bumped version to 0.3.0
This commit is contained in:
@ -86,6 +86,8 @@ By: Codex (GPT-5)
|
|||||||
| 15.01.2026 | 🎨 UI | ID: System Information Kacheln mit Progressbars verbessert. By: Codex (GPT-5) |
|
| 15.01.2026 | 🎨 UI | ID: System Information Kacheln mit Progressbars verbessert. By: Codex (GPT-5) |
|
||||||
| 15.01.2026 | 🚀 Release | ID: VERSION auf 0.2.3 erhoeht. By: Codex (GPT-5) |
|
| 15.01.2026 | 🚀 Release | ID: VERSION auf 0.2.3 erhoeht. By: Codex (GPT-5) |
|
||||||
| 15.01.2026 | ⚙️ Code | ID: Makefile restart-Target hinzugefuegt. By: Codex (GPT-5) |
|
| 15.01.2026 | ⚙️ Code | ID: Makefile restart-Target hinzugefuegt. By: Codex (GPT-5) |
|
||||||
|
| 16.01.2026 | ✨ Feat | ID: EPIC_000014/US_000045 Login-Regeln und Scheduler implementiert (UI + Backend). By: Gemini CLI |
|
||||||
|
| 16.01.2026 | 🚀 Release | ID: VERSION auf 0.3.0 erhoeht. By: Gemini CLI |
|
||||||
|
|
||||||
---
|
---
|
||||||
## Legende
|
## Legende
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
@ -6,7 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from backend import actions
|
from backend import actions, enforcer, rules
|
||||||
from backend.actions import ActionError
|
from backend.actions import ActionError
|
||||||
from backend.auth import (
|
from backend.auth import (
|
||||||
authenticate_admin_user,
|
authenticate_admin_user,
|
||||||
@ -17,12 +18,14 @@ from backend.auth import (
|
|||||||
list_manageable_users,
|
list_manageable_users,
|
||||||
)
|
)
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
|
AccessRule,
|
||||||
ActionRequest,
|
ActionRequest,
|
||||||
ActionResponse,
|
ActionResponse,
|
||||||
EnrollRequest,
|
EnrollRequest,
|
||||||
EnrollResponse,
|
EnrollResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
|
RuleSet,
|
||||||
UpdateActionResponse,
|
UpdateActionResponse,
|
||||||
UpdateCheckResponse,
|
UpdateCheckResponse,
|
||||||
UpdateLogEntry,
|
UpdateLogEntry,
|
||||||
@ -48,6 +51,11 @@ app.mount("/assets", StaticFiles(directory="assets"), name="assets")
|
|||||||
templates = Jinja2Templates(directory="backend/templates")
|
templates = Jinja2Templates(directory="backend/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
asyncio.create_task(enforcer.enforcement_loop())
|
||||||
|
|
||||||
|
|
||||||
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
|
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
|
||||||
if not settings.oidc_enabled:
|
if not settings.oidc_enabled:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -238,6 +246,32 @@ def enable_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/rules", response_model=RuleSet, dependencies=[Depends(get_current_admin)])
|
||||||
|
def get_rules(settings: Settings = Depends(get_settings)) -> RuleSet:
|
||||||
|
manager = rules.RuleManager(settings)
|
||||||
|
return manager.load_rules()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/rules", dependencies=[Depends(get_current_admin)])
|
||||||
|
def set_rule(
|
||||||
|
rule: AccessRule,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
) -> dict:
|
||||||
|
manager = rules.RuleManager(settings)
|
||||||
|
manager.set_rule(rule)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/rules/{username}", dependencies=[Depends(get_current_admin)])
|
||||||
|
def delete_rule(
|
||||||
|
username: str,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
) -> dict:
|
||||||
|
manager = rules.RuleManager(settings)
|
||||||
|
manager.delete_rule(username)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/update/status", response_model=UpdateStatus, dependencies=[Depends(get_current_admin)])
|
@app.get("/update/status", response_model=UpdateStatus, dependencies=[Depends(get_current_admin)])
|
||||||
def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
|
def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
|
||||||
status_data = update.get_status(settings)
|
status_data = update.get_status(settings)
|
||||||
@ -334,3 +368,9 @@ def system_metrics_status() -> SystemMetrics:
|
|||||||
@app.get("/dashboard", response_class=HTMLResponse)
|
@app.get("/dashboard", response_class=HTMLResponse)
|
||||||
def index(request: Request) -> HTMLResponse:
|
def index(request: Request) -> HTMLResponse:
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ui/rules", response_class=HTMLResponse)
|
||||||
|
def rules_ui(request: Request) -> HTMLResponse:
|
||||||
|
return templates.TemplateResponse("rules.html", {"request": request})
|
||||||
|
|
||||||
|
|||||||
71
backend/enforcer.py
Normal file
71
backend/enforcer.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from backend import actions
|
||||||
|
from backend.auth import is_account_locked, list_manageable_users
|
||||||
|
from backend.rules import RuleManager
|
||||||
|
from backend.settings import get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_and_enforce_rules():
|
||||||
|
"""Iterate all manageable users and enforce time window rules."""
|
||||||
|
settings = get_settings()
|
||||||
|
manager = RuleManager(settings)
|
||||||
|
|
||||||
|
# We only care about users explicitly managed via settings or rules.
|
||||||
|
target_users = list_manageable_users(settings)
|
||||||
|
|
||||||
|
for user in target_users:
|
||||||
|
try:
|
||||||
|
allowed = manager.is_login_allowed(user)
|
||||||
|
rule = manager.get_rule(user)
|
||||||
|
|
||||||
|
if allowed:
|
||||||
|
# If user has a rule with auto-reenable, ensure unlocked
|
||||||
|
# We check rule existence because "allowed" is also true for users with NO rules.
|
||||||
|
# But for users with no rules, we don't want to auto-unlock randomly (maybe manual lock?).
|
||||||
|
# US says: "Given a rule allows automatic reactivation ... Then account is automatically activated"
|
||||||
|
# So we only auto-unlock if a rule EXISTS and explicitly asks for it.
|
||||||
|
if rule and rule.auto_reenable and is_account_locked(user):
|
||||||
|
logger.info("Auto-enabling user %s (Time window started)", user)
|
||||||
|
actions.enable_user(user)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we are here, access is DENIED.
|
||||||
|
|
||||||
|
# Check if logged in
|
||||||
|
logged_in_users = actions.list_logged_in_users()
|
||||||
|
is_logged_in = user in logged_in_users
|
||||||
|
|
||||||
|
if is_logged_in:
|
||||||
|
logger.warning("User %s is logged in during forbidden time. Enforcing logout.", user)
|
||||||
|
# Warn and shutdown
|
||||||
|
actions.disable_user(
|
||||||
|
user,
|
||||||
|
countdown=60,
|
||||||
|
sound=True,
|
||||||
|
message="Time limit reached. Shutdown in 60s.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Not logged in. Ensure account is locked to prevent login.
|
||||||
|
if not is_account_locked(user):
|
||||||
|
logger.info("Locking user %s (Time window ended)", user)
|
||||||
|
# disable_user locks the account. We pass countdown=0 but since not logged in, it won't matter much.
|
||||||
|
actions.disable_user(user, countdown=0, sound=False)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error enforcing rules for user %s", user)
|
||||||
|
|
||||||
|
|
||||||
|
async def enforcement_loop():
|
||||||
|
logger.info("Starting enforcement loop")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await check_and_enforce_rules()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error in enforcement loop")
|
||||||
|
|
||||||
|
# Run every minute
|
||||||
|
await asyncio.sleep(60)
|
||||||
@ -1,8 +1,36 @@
|
|||||||
from typing import List, Optional
|
from enum import Enum
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class DayOfWeek(str, Enum):
|
||||||
|
MONDAY = "mon"
|
||||||
|
TUESDAY = "tue"
|
||||||
|
WEDNESDAY = "wed"
|
||||||
|
THURSDAY = "thu"
|
||||||
|
FRIDAY = "fri"
|
||||||
|
SATURDAY = "sat"
|
||||||
|
SUNDAY = "sun"
|
||||||
|
|
||||||
|
|
||||||
|
class TimeWindow(BaseModel):
|
||||||
|
days: List[DayOfWeek]
|
||||||
|
start_time: str = Field(..., pattern=r"^\d{2}:\d{2}$", description="HH:MM format")
|
||||||
|
end_time: str = Field(..., pattern=r"^\d{2}:\d{2}$", description="HH:MM format")
|
||||||
|
|
||||||
|
|
||||||
|
class AccessRule(BaseModel):
|
||||||
|
user: str
|
||||||
|
enabled: bool = True
|
||||||
|
auto_reenable: bool = False
|
||||||
|
allowed_windows: List[TimeWindow] = []
|
||||||
|
|
||||||
|
|
||||||
|
class RuleSet(BaseModel):
|
||||||
|
rules: Dict[str, AccessRule] = {} # Keyed by username for O(1) lookup
|
||||||
|
|
||||||
|
|
||||||
class ActionRequest(BaseModel):
|
class ActionRequest(BaseModel):
|
||||||
countdown: Optional[int] = Field(default=None, ge=0, description="Seconds for countdown")
|
countdown: Optional[int] = Field(default=None, ge=0, description="Seconds for countdown")
|
||||||
sound: Optional[bool] = Field(default=None, description="Play sound alongside notification")
|
sound: Optional[bool] = Field(default=None, description="Play sound alongside notification")
|
||||||
|
|||||||
91
backend/rules.py
Normal file
91
backend/rules.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from backend.models import RuleSet, AccessRule, DayOfWeek
|
||||||
|
from backend.settings import get_settings, Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RuleManager:
|
||||||
|
def __init__(self, settings: Optional[Settings] = None):
|
||||||
|
self.settings = settings or get_settings()
|
||||||
|
self.rules_file = self.settings.rules_file
|
||||||
|
|
||||||
|
def load_rules(self) -> RuleSet:
|
||||||
|
if not os.path.exists(self.rules_file):
|
||||||
|
return RuleSet()
|
||||||
|
try:
|
||||||
|
with open(self.rules_file, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return RuleSet(**data)
|
||||||
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
|
logger.error("Failed to load rules from %s: %s", self.rules_file, e)
|
||||||
|
return RuleSet()
|
||||||
|
|
||||||
|
def save_rules(self, rules: RuleSet) -> None:
|
||||||
|
try:
|
||||||
|
os.makedirs(os.path.dirname(self.rules_file), exist_ok=True)
|
||||||
|
with open(self.rules_file, "w", encoding="utf-8") as f:
|
||||||
|
f.write(rules.model_dump_json(indent=2))
|
||||||
|
except OSError as e:
|
||||||
|
logger.error("Failed to save rules to %s: %s", self.rules_file, e)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_rule(self, user: str) -> Optional[AccessRule]:
|
||||||
|
rules = self.load_rules()
|
||||||
|
return rules.rules.get(user)
|
||||||
|
|
||||||
|
def set_rule(self, rule: AccessRule) -> None:
|
||||||
|
rules = self.load_rules()
|
||||||
|
rules.rules[rule.user] = rule
|
||||||
|
self.save_rules(rules)
|
||||||
|
|
||||||
|
def delete_rule(self, user: str) -> None:
|
||||||
|
rules = self.load_rules()
|
||||||
|
if user in rules.rules:
|
||||||
|
del rules.rules[user]
|
||||||
|
self.save_rules(rules)
|
||||||
|
|
||||||
|
def is_login_allowed(self, user: str, now: Optional[datetime] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Check if user is allowed to login at the given time (or now).
|
||||||
|
Returns True if no rule exists (or disabled) or if time is within an allowed window.
|
||||||
|
Returns False if a rule exists and current time is outside all allowed windows.
|
||||||
|
"""
|
||||||
|
rule = self.get_rule(user)
|
||||||
|
if not rule or not rule.enabled:
|
||||||
|
# No rule or rule disabled -> Default Allow
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not rule.allowed_windows:
|
||||||
|
# Rule enabled but no windows -> Deny All
|
||||||
|
return False
|
||||||
|
|
||||||
|
if now is None:
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
# Map weekday to Enum
|
||||||
|
weekday_map = {
|
||||||
|
0: DayOfWeek.MONDAY,
|
||||||
|
1: DayOfWeek.TUESDAY,
|
||||||
|
2: DayOfWeek.WEDNESDAY,
|
||||||
|
3: DayOfWeek.THURSDAY,
|
||||||
|
4: DayOfWeek.FRIDAY,
|
||||||
|
5: DayOfWeek.SATURDAY,
|
||||||
|
6: DayOfWeek.SUNDAY,
|
||||||
|
}
|
||||||
|
current_day = weekday_map[now.weekday()]
|
||||||
|
current_time_str = now.strftime("%H:%M")
|
||||||
|
|
||||||
|
for window in rule.allowed_windows:
|
||||||
|
if current_day in window.days:
|
||||||
|
# Handle simple range start <= now <= end
|
||||||
|
# (Overnight windows assumed to be split by user into two windows)
|
||||||
|
if window.start_time <= current_time_str <= window.end_time:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
@ -57,6 +57,9 @@ class Settings:
|
|||||||
self.update_log_file: str = os.getenv(
|
self.update_log_file: str = os.getenv(
|
||||||
"SKD_UPDATE_LOG_FILE", "/var/lib/skd/update_logs.jsonl"
|
"SKD_UPDATE_LOG_FILE", "/var/lib/skd/update_logs.jsonl"
|
||||||
)
|
)
|
||||||
|
self.rules_file: str = os.getenv(
|
||||||
|
"SKD_RULES_FILE", "/var/lib/skd/rules.json"
|
||||||
|
)
|
||||||
# Paths/tools
|
# Paths/tools
|
||||||
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
|
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
|
||||||
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")
|
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")
|
||||||
|
|||||||
@ -128,10 +128,16 @@
|
|||||||
<section id="userSection" class="panel-section hidden">
|
<section id="userSection" class="panel-section hidden">
|
||||||
<h3><i data-lucide="users"></i> Nutzerverwaltung</h3>
|
<h3><i data-lucide="users"></i> Nutzerverwaltung</h3>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||||
<button id="refreshUsersBtn" class="secondary small">
|
<button id="refreshUsersBtn" class="secondary small">
|
||||||
<i data-lucide="refresh-cw"></i>
|
<i data-lucide="refresh-cw"></i>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
|
<a href="/ui/rules" class="button secondary small">
|
||||||
|
<i data-lucide="clock"></i>
|
||||||
|
Regeln verwalten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table class="user-table mt-1">
|
<table class="user-table mt-1">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
442
backend/templates/rules.html
Normal file
442
backend/templates/rules.html
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Regelverwaltung - Safe Kiddo</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/assets/branding/favicon.svg" />
|
||||||
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
|
<style>
|
||||||
|
.rule-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 250px 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.user-list {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.user-item {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border-main);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.user-item:last-child { border-bottom: none; }
|
||||||
|
.user-item:hover { background: rgba(255,255,255,0.05); }
|
||||||
|
.user-item.active { background: rgba(var(--primary-rgb), 0.1); border-left: 3px solid var(--primary); }
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.window-item {
|
||||||
|
background: var(--bg-main);
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.day-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.day-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.day-badge.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.time-range {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.remove-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--color-error);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.day-checkbox {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.day-label {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid var(--border-main);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.day-checkbox:checked + .day-label {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle dark mode">
|
||||||
|
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||||
|
</svg>
|
||||||
|
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<div class="header-main">
|
||||||
|
<h1>
|
||||||
|
<i data-lucide="clock"></i>
|
||||||
|
Regelverwaltung
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<a href="/dashboard" class="button secondary small">
|
||||||
|
<i data-lucide="arrow-left"></i> Zurück zum Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rule-editor" id="ruleEditor">
|
||||||
|
<!-- User List -->
|
||||||
|
<div class="user-list" id="userList">
|
||||||
|
<div style="padding: 1rem; text-align: center; color: var(--text-muted);">
|
||||||
|
Lade Nutzer...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor Panel -->
|
||||||
|
<div class="editor-panel hidden" id="editorPanel">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
|
||||||
|
<h2 id="selectedUserTitle" style="margin: 0;">Benutzer</h2>
|
||||||
|
<div style="display: flex; gap: 0.5rem;">
|
||||||
|
<button class="danger small" id="deleteRulesBtn">Regeln löschen</button>
|
||||||
|
<button class="primary small" id="saveRulesBtn">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<input type="checkbox" id="ruleEnabled" checked>
|
||||||
|
<label for="ruleEnabled">Regelwerk aktiv</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group checkbox-group">
|
||||||
|
<input type="checkbox" id="autoReenable">
|
||||||
|
<label for="autoReenable">Automatisch entsperren (wenn Zeitfenster beginnt)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Erlaubte Zeitfenster</h3>
|
||||||
|
<div class="window-list" id="windowList">
|
||||||
|
<!-- Windows go here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="secondary small mt-1" onclick="openAddWindowModal()">
|
||||||
|
<i data-lucide="plus"></i> Zeitfenster hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-panel" id="emptyState" style="text-align: center; padding: 3rem;">
|
||||||
|
<i data-lucide="user" style="width: 48px; height: 48px; color: var(--text-muted); margin-bottom: 1rem;"></i>
|
||||||
|
<p class="text-muted">Wähle einen Benutzer aus, um Regeln zu bearbeiten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Window Modal -->
|
||||||
|
<div class="modal" id="addWindowModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Zeitfenster hinzufügen</h3>
|
||||||
|
<button class="secondary small" onclick="closeAddWindowModal()">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="addWindowForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Wochentage</label>
|
||||||
|
<div class="day-selector">
|
||||||
|
<label><input type="checkbox" class="day-checkbox" value="mon"><span class="day-label">Mo</span></label>
|
||||||
|
<label><input type="checkbox" class="day-checkbox" value="tue"><span class="day-label">Di</span></label>
|
||||||
|
<label><input type="checkbox" class="day-checkbox" value="wed"><span class="day-label">Mi</span></label>
|
||||||
|
<label><input type="checkbox" class="day-checkbox" value="thu"><span class="day-label">Do</span></label>
|
||||||
|
<label><input type="checkbox" class="day-checkbox" value="fri"><span class="day-label">Fr</span></label>
|
||||||
|
<label><input type="checkbox" class="day-checkbox" value="sat"><span class="day-label">Sa</span></label>
|
||||||
|
<label><input type="checkbox" class="day-checkbox" value="sun"><span class="day-label">So</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Start</label>
|
||||||
|
<input type="time" id="startTime" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Ende</label>
|
||||||
|
<input type="time" id="endTime" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="secondary" onclick="closeAddWindowModal()">Abbrechen</button>
|
||||||
|
<button type="submit">Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Container -->
|
||||||
|
<div class="toast-container" id="toastContainer"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
// State
|
||||||
|
let users = [];
|
||||||
|
let currentRules = {}; // Keyed by username
|
||||||
|
let selectedUser = null;
|
||||||
|
let currentWindows = []; // Temp storage for editor
|
||||||
|
|
||||||
|
// API
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const token = sessionStorage.getItem('skdToken');
|
||||||
|
if (!token) window.location.href = '/login';
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
...(options.headers || {})
|
||||||
|
};
|
||||||
|
const res = await fetch(path, { ...options, headers });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.innerHTML = `
|
||||||
|
<i data-lucide="${type === 'success' ? 'check-circle' : 'alert-circle'}"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
`;
|
||||||
|
container.appendChild(toast);
|
||||||
|
lucide.createIcons();
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.animation = 'slideIn 0.3s ease reverse';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
// Load users and rules in parallel
|
||||||
|
const [usersData, rulesData] = await Promise.all([
|
||||||
|
api('/users'),
|
||||||
|
api('/rules')
|
||||||
|
]);
|
||||||
|
|
||||||
|
users = usersData.map(u => u.user);
|
||||||
|
currentRules = rulesData.rules || {};
|
||||||
|
|
||||||
|
renderUserList();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Fehler beim Laden: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUserList() {
|
||||||
|
const list = document.getElementById('userList');
|
||||||
|
if (users.length === 0) {
|
||||||
|
list.innerHTML = '<div style="padding:1rem;">Keine Nutzer gefunden.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = users.map(u => {
|
||||||
|
const hasRule = !!currentRules[u];
|
||||||
|
return `
|
||||||
|
<div class="user-item ${selectedUser === u ? 'active' : ''}" onclick="selectUser('${u}')">
|
||||||
|
<div>
|
||||||
|
<strong>${u}</strong>
|
||||||
|
${hasRule ? '<i data-lucide="check" style="width:14px; height:14px; margin-left:4px; color:var(--color-success)"></i>' : ''}
|
||||||
|
</div>
|
||||||
|
<i data-lucide="chevron-right" style="width:16px; height:16px; color:var(--text-muted)"></i>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectUser(user) {
|
||||||
|
selectedUser = user;
|
||||||
|
renderUserList(); // Update active state
|
||||||
|
|
||||||
|
document.getElementById('emptyState').classList.add('hidden');
|
||||||
|
document.getElementById('editorPanel').classList.remove('hidden');
|
||||||
|
document.getElementById('selectedUserTitle').textContent = user;
|
||||||
|
|
||||||
|
const rule = currentRules[user] || { enabled: true, auto_reenable: false, allowed_windows: [] };
|
||||||
|
|
||||||
|
document.getElementById('ruleEnabled').checked = rule.enabled;
|
||||||
|
document.getElementById('autoReenable').checked = rule.auto_reenable;
|
||||||
|
currentWindows = [...(rule.allowed_windows || [])];
|
||||||
|
|
||||||
|
renderWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWindows() {
|
||||||
|
const container = document.getElementById('windowList');
|
||||||
|
if (currentWindows.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-muted" style="font-size:0.9rem;">Keine Zeitfenster definiert. Login ist standardmäßig <strong>nicht erlaubt</strong> (wenn Regelwerk aktiv).</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayMap = { mon:'Mo', tue:'Di', wed:'Mi', thu:'Do', fri:'Fr', sat:'Sa', sun:'So' };
|
||||||
|
const allDays = ['mon','tue','wed','thu','fri','sat','sun'];
|
||||||
|
|
||||||
|
container.innerHTML = currentWindows.map((win, idx) => {
|
||||||
|
const badges = allDays.map(d => `
|
||||||
|
<span class="day-badge ${win.days.includes(d) ? 'active' : ''}">${dayMap[d]}</span>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="window-item">
|
||||||
|
<div class="day-badges">${badges}</div>
|
||||||
|
<div class="time-range">
|
||||||
|
${win.start_time} - ${win.end_time}
|
||||||
|
</div>
|
||||||
|
<button class="remove-btn" onclick="removeWindow(${idx})">
|
||||||
|
<i data-lucide="trash-2" style="width:16px; height:16px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWindow(index) {
|
||||||
|
currentWindows.splice(index, 1);
|
||||||
|
renderWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddWindowModal() {
|
||||||
|
document.getElementById('addWindowForm').reset();
|
||||||
|
// Default checkboxes
|
||||||
|
document.querySelectorAll('.day-checkbox').forEach(cb => {
|
||||||
|
if (['mon','tue','wed','thu','fri'].includes(cb.value)) cb.checked = true;
|
||||||
|
else cb.checked = false;
|
||||||
|
});
|
||||||
|
document.getElementById('addWindowModal').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddWindowModal() {
|
||||||
|
document.getElementById('addWindowModal').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('addWindowForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const days = Array.from(document.querySelectorAll('.day-checkbox:checked')).map(cb => cb.value);
|
||||||
|
if (days.length === 0) {
|
||||||
|
showToast('Bitte mindestens einen Wochentag wählen.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = document.getElementById('startTime').value;
|
||||||
|
const end = document.getElementById('endTime').value;
|
||||||
|
|
||||||
|
if (start >= end) {
|
||||||
|
showToast('Endzeit muss nach Startzeit liegen.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentWindows.push({ days, start_time: start, end_time: end });
|
||||||
|
currentWindows.sort((a, b) => a.start_time.localeCompare(b.start_time));
|
||||||
|
|
||||||
|
closeAddWindowModal();
|
||||||
|
renderWindows();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveRulesBtn').addEventListener('click', async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
user: selectedUser,
|
||||||
|
enabled: document.getElementById('ruleEnabled').checked,
|
||||||
|
auto_reenable: document.getElementById('autoReenable').checked,
|
||||||
|
allowed_windows: currentWindows
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api('/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(rule)
|
||||||
|
});
|
||||||
|
currentRules[selectedUser] = rule;
|
||||||
|
showToast('Regeln gespeichert', 'success');
|
||||||
|
renderUserList();
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Speichern fehlgeschlagen: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('deleteRulesBtn').addEventListener('click', async () => {
|
||||||
|
if (!selectedUser || !confirm(`Regeln für ${selectedUser} wirklich löschen?\nDer Nutzer hat dann uneingeschränkten Zugriff.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api(`/rules/${selectedUser}`, { method: 'DELETE' });
|
||||||
|
delete currentRules[selectedUser];
|
||||||
|
showToast('Regeln gelöscht', 'success');
|
||||||
|
selectUser(selectedUser); // Refresh view (defaults)
|
||||||
|
} catch (err) {
|
||||||
|
showToast('Löschen fehlgeschlagen: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dark Mode (Shared)
|
||||||
|
function toggleTheme() {
|
||||||
|
document.body.classList.toggle('dark-mode');
|
||||||
|
localStorage.setItem('darkMode', document.body.classList.contains('dark-mode'));
|
||||||
|
}
|
||||||
|
if (localStorage.getItem('darkMode') === 'true') {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
ID: STATUS_000001 | Version: 0.2.3 | Status: Final
|
ID: STATUS_000001 | Version: 0.3.0 | Status: Final
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# Projekt-Status
|
# Projekt-Status
|
||||||
@ -23,6 +23,7 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
|||||||
| 15.01.2026 | 📝 Req | Externe Service-Links in der Doku ergaenzt. |
|
| 15.01.2026 | 📝 Req | Externe Service-Links in der Doku ergaenzt. |
|
||||||
| 15.01.2026 | 📝 Req | Makefile als Einstieg in Doku aufgenommen. |
|
| 15.01.2026 | 📝 Req | Makefile als Einstieg in Doku aufgenommen. |
|
||||||
| 15.01.2026 | ⚙️ Code | Makefile restart-Target hinzugefuegt. |
|
| 15.01.2026 | ⚙️ Code | Makefile restart-Target hinzugefuegt. |
|
||||||
|
| 16.01.2026 | ✨ Feat | Login-Zeitfenster (Rules & Scheduler) implementiert (US_000045). |
|
||||||
|
|
||||||
## Epic-Backlog (Uebersicht)
|
## Epic-Backlog (Uebersicht)
|
||||||
### EPIC_000001: Legacy CLI Account Control (sk.sh)
|
### EPIC_000001: Legacy CLI Account Control (sk.sh)
|
||||||
@ -153,10 +154,10 @@ Sicheres, remote steuerbares System zum Sperren/Entsperren lokaler Nutzerkonten.
|
|||||||
- [x] TASK_000051: UI-Kacheln im Dashboard (System Information)
|
- [x] TASK_000051: UI-Kacheln im Dashboard (System Information)
|
||||||
|
|
||||||
### EPIC_000014: Login-Zeitfenster und Parent-Control Regeln
|
### EPIC_000014: Login-Zeitfenster und Parent-Control Regeln
|
||||||
- [ ] US_000045: Regeln fuer Login-Zeitfenster definieren und durchsetzen
|
- [x] US_000045: Regeln fuer Login-Zeitfenster definieren und durchsetzen
|
||||||
- [ ] TASK_000052: Regelmodell und Speicherung definieren
|
- [x] TASK_000052: Regelmodell und Speicherung definieren
|
||||||
- [ ] TASK_000053: Login-Pruefung und Enforcement
|
- [x] TASK_000053: Login-Pruefung und Enforcement
|
||||||
- [ ] TASK_000054: Admin-UI fuer Regeln und Scheduler
|
- [x] TASK_000054: Admin-UI fuer Regeln und Scheduler
|
||||||
|
|
||||||
## Offene Risiken / Abhaengigkeiten
|
## Offene Risiken / Abhaengigkeiten
|
||||||
- Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn).
|
- Betrieb erfordert Root/sudo und lokale System-Tools (notify-send, sound player, uvicorn).
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
ID: US_000045 | Version: 0.2.3 | Status: Draft
|
ID: US_000045 | Version: 0.2.3 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# US_000045: Regeln fuer Login-Zeitfenster definieren und durchsetzen
|
# US_000045: Regeln fuer Login-Zeitfenster definieren und durchsetzen
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
ID: TASK_000052 | Version: 0.2.3 | Status: Draft
|
ID: TASK_000052 | Version: 0.2.3 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# TASK_000052: Regelmodell und Speicherung definieren
|
# TASK_000052: Regelmodell und Speicherung definieren
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
ID: TASK_000053 | Version: 0.2.3 | Status: Draft
|
ID: TASK_000053 | Version: 0.2.3 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# TASK_000053: Login-Pruefung und Enforcement
|
# TASK_000053: Login-Pruefung und Enforcement
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
ID: TASK_000054 | Version: 0.2.3 | Status: Draft
|
ID: TASK_000054 | Version: 0.2.3 | Status: Done
|
||||||
By: Codex (GPT-5)
|
By: Codex (GPT-5)
|
||||||
|
|
||||||
# TASK_000054: Admin-UI fuer Regeln und Scheduler
|
# TASK_000054: Admin-UI fuer Regeln und Scheduler
|
||||||
|
|||||||
56
tests/test_api_rules.py
Normal file
56
tests/test_api_rules.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# Mock pam module before importing backend components
|
||||||
|
sys.modules["pam"] = MagicMock()
|
||||||
|
|
||||||
|
from backend.settings import Settings
|
||||||
|
from backend.models import AccessRule, TimeWindow, DayOfWeek
|
||||||
|
|
||||||
|
# Import endpoint functions directly
|
||||||
|
from backend.app import get_rules, set_rule, delete_rule
|
||||||
|
|
||||||
|
class TestRulesDirect(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp_fd, self.rules_file = tempfile.mkstemp()
|
||||||
|
os.close(self.tmp_fd)
|
||||||
|
|
||||||
|
self.settings = Settings()
|
||||||
|
self.settings.rules_file = self.rules_file
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.rules_file):
|
||||||
|
os.remove(self.rules_file)
|
||||||
|
|
||||||
|
def test_crud_rules_direct(self):
|
||||||
|
# 1. Get empty rules
|
||||||
|
rules = get_rules(settings=self.settings)
|
||||||
|
self.assertEqual(rules.rules, {})
|
||||||
|
|
||||||
|
# 2. Create rule
|
||||||
|
rule = AccessRule(
|
||||||
|
user="kid",
|
||||||
|
enabled=True,
|
||||||
|
allowed_windows=[
|
||||||
|
TimeWindow(days=[DayOfWeek.MONDAY], start_time="14:00", end_time="16:00")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
set_rule(rule, settings=self.settings)
|
||||||
|
|
||||||
|
# 3. Verify created
|
||||||
|
rules = get_rules(settings=self.settings)
|
||||||
|
self.assertIn("kid", rules.rules)
|
||||||
|
self.assertEqual(rules.rules["kid"].allowed_windows[0].start_time, "14:00")
|
||||||
|
|
||||||
|
# 4. Delete rule
|
||||||
|
delete_rule("kid", settings=self.settings)
|
||||||
|
|
||||||
|
# 5. Verify deleted
|
||||||
|
rules = get_rules(settings=self.settings)
|
||||||
|
self.assertNotIn("kid", rules.rules)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
95
tests/test_rules.py
Normal file
95
tests/test_rules.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Adjust path if necessary, assuming run from project root
|
||||||
|
from backend.models import AccessRule, TimeWindow, DayOfWeek
|
||||||
|
from backend.rules import RuleManager
|
||||||
|
|
||||||
|
class TestRules(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp_fd, self.rules_file = tempfile.mkstemp()
|
||||||
|
os.close(self.tmp_fd)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
if os.path.exists(self.rules_file):
|
||||||
|
os.remove(self.rules_file)
|
||||||
|
|
||||||
|
@patch("backend.rules.get_settings")
|
||||||
|
def test_load_save_rules(self, mock_settings):
|
||||||
|
mock_settings.return_value.rules_file = self.rules_file
|
||||||
|
manager = RuleManager()
|
||||||
|
|
||||||
|
# Should be empty initially
|
||||||
|
rules = manager.load_rules()
|
||||||
|
self.assertEqual(rules.rules, {})
|
||||||
|
|
||||||
|
# Save a rule
|
||||||
|
rule = AccessRule(
|
||||||
|
user="testuser",
|
||||||
|
allowed_windows=[
|
||||||
|
TimeWindow(days=[DayOfWeek.MONDAY], start_time="09:00", end_time="17:00")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
manager.set_rule(rule)
|
||||||
|
|
||||||
|
# Load back
|
||||||
|
loaded = manager.get_rule("testuser")
|
||||||
|
self.assertIsNotNone(loaded)
|
||||||
|
self.assertEqual(loaded.user, "testuser")
|
||||||
|
self.assertEqual(loaded.allowed_windows[0].start_time, "09:00")
|
||||||
|
|
||||||
|
@patch("backend.rules.get_settings")
|
||||||
|
def test_access_logic(self, mock_settings):
|
||||||
|
mock_settings.return_value.rules_file = self.rules_file
|
||||||
|
manager = RuleManager()
|
||||||
|
|
||||||
|
# No rule -> Allowed
|
||||||
|
self.assertTrue(manager.is_login_allowed("unknown"))
|
||||||
|
|
||||||
|
# Add rule: Mon 10-12
|
||||||
|
rule = AccessRule(
|
||||||
|
user="kid",
|
||||||
|
allowed_windows=[
|
||||||
|
TimeWindow(days=[DayOfWeek.MONDAY], start_time="10:00", end_time="12:00")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
manager.set_rule(rule)
|
||||||
|
|
||||||
|
# Monday 11:00 -> Allowed
|
||||||
|
# Jan 2 2023 is Monday
|
||||||
|
mon_11 = datetime(2023, 1, 2, 11, 0)
|
||||||
|
self.assertTrue(manager.is_login_allowed("kid", mon_11))
|
||||||
|
|
||||||
|
# Monday 09:00 -> Denied
|
||||||
|
mon_09 = datetime(2023, 1, 2, 9, 0)
|
||||||
|
self.assertFalse(manager.is_login_allowed("kid", mon_09))
|
||||||
|
|
||||||
|
# Tuesday 11:00 -> Denied
|
||||||
|
# Jan 3 2023 is Tuesday
|
||||||
|
tue_11 = datetime(2023, 1, 3, 11, 0)
|
||||||
|
self.assertFalse(manager.is_login_allowed("kid", tue_11))
|
||||||
|
|
||||||
|
@patch("backend.rules.get_settings")
|
||||||
|
def test_disabled_rule(self, mock_settings):
|
||||||
|
mock_settings.return_value.rules_file = self.rules_file
|
||||||
|
manager = RuleManager()
|
||||||
|
|
||||||
|
# Add disabled rule
|
||||||
|
rule = AccessRule(
|
||||||
|
user="kid",
|
||||||
|
enabled=False,
|
||||||
|
allowed_windows=[
|
||||||
|
TimeWindow(days=[DayOfWeek.MONDAY], start_time="10:00", end_time="12:00")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
manager.set_rule(rule)
|
||||||
|
|
||||||
|
# Should be allowed even if time doesn't match, because rule is disabled (default allow)
|
||||||
|
mon_09 = datetime(2023, 1, 2, 9, 0)
|
||||||
|
self.assertTrue(manager.is_login_allowed("kid", mon_09))
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user