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 | 🚀 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) |
|
||||
| 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
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
@ -6,7 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from backend import actions
|
||||
from backend import actions, enforcer, rules
|
||||
from backend.actions import ActionError
|
||||
from backend.auth import (
|
||||
authenticate_admin_user,
|
||||
@ -17,12 +18,14 @@ from backend.auth import (
|
||||
list_manageable_users,
|
||||
)
|
||||
from backend.models import (
|
||||
AccessRule,
|
||||
ActionRequest,
|
||||
ActionResponse,
|
||||
EnrollRequest,
|
||||
EnrollResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RuleSet,
|
||||
UpdateActionResponse,
|
||||
UpdateCheckResponse,
|
||||
UpdateLogEntry,
|
||||
@ -48,6 +51,11 @@ app.mount("/assets", StaticFiles(directory="assets"), name="assets")
|
||||
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:
|
||||
if not settings.oidc_enabled:
|
||||
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)])
|
||||
def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
|
||||
status_data = update.get_status(settings)
|
||||
@ -334,3 +368,9 @@ def system_metrics_status() -> SystemMetrics:
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
def index(request: Request) -> HTMLResponse:
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
countdown: Optional[int] = Field(default=None, ge=0, description="Seconds for countdown")
|
||||
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(
|
||||
"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
|
||||
self.notify_send_path: str = os.getenv("SKD_NOTIFY_SEND_PATH", "notify-send")
|
||||
self.sound_player: str = os.getenv("SKD_SOUND_PLAYER", "paplay")
|
||||
|
||||
@ -128,10 +128,16 @@
|
||||
<section id="userSection" class="panel-section hidden">
|
||||
<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">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<a href="/ui/rules" class="button secondary small">
|
||||
<i data-lucide="clock"></i>
|
||||
Regeln verwalten
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<table class="user-table mt-1">
|
||||
<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)
|
||||
|
||||
# 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 | Makefile als Einstieg in Doku aufgenommen. |
|
||||
| 15.01.2026 | ⚙️ Code | Makefile restart-Target hinzugefuegt. |
|
||||
| 16.01.2026 | ✨ Feat | Login-Zeitfenster (Rules & Scheduler) implementiert (US_000045). |
|
||||
|
||||
## Epic-Backlog (Uebersicht)
|
||||
### 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)
|
||||
|
||||
### EPIC_000014: Login-Zeitfenster und Parent-Control Regeln
|
||||
- [ ] US_000045: Regeln fuer Login-Zeitfenster definieren und durchsetzen
|
||||
- [ ] TASK_000052: Regelmodell und Speicherung definieren
|
||||
- [ ] TASK_000053: Login-Pruefung und Enforcement
|
||||
- [ ] TASK_000054: Admin-UI fuer Regeln und Scheduler
|
||||
- [x] US_000045: Regeln fuer Login-Zeitfenster definieren und durchsetzen
|
||||
- [x] TASK_000052: Regelmodell und Speicherung definieren
|
||||
- [x] TASK_000053: Login-Pruefung und Enforcement
|
||||
- [x] TASK_000054: Admin-UI fuer Regeln und Scheduler
|
||||
|
||||
## Offene Risiken / Abhaengigkeiten
|
||||
- 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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