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:
2026-01-16 11:25:56 +01:00
parent 9ad9501722
commit 63ee0b40b5
16 changed files with 851 additions and 16 deletions

View File

@ -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

View File

@ -1 +1 @@
0.2.3
0.3.0

View File

@ -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
View 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)

View File

@ -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
View 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

View File

@ -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")

View File

@ -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>

View 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>

View File

@ -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).

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
View 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
View 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()