elemes/services/teacher_bootstrap.py

149 lines
5.2 KiB
Python

"""
Bootstrap/manajemen akun guru canonical (satu-satunya user teacher).
Kontrak:
- Database mempertahankan TEPAT SATU akun guru (role='teacher').
- Jika belum ada guru → buat satu user + satu access token.
- Jika sudah ada guru → update display_name + revoke token lama + buat
token baru pada user/UUID yang sama (upsert), tanpa menambah jumlah guru.
- Token yang sama dengan token aktif guru → update nama saja (idempotent).
- Token yang sudah dimiliki siswa → tolak seluruh operasi.
- Lebih dari satu guru (data legacy) → fail closed, minta normalisasi manual.
Keamanan:
- Tidak pernah mencetak raw token maupun digest.
- Satu transaksi; error → rollback total.
"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from services.models import AccessToken, User
from services.repositories import (
create_access_token,
create_user,
find_user_by_raw_token,
hash_token,
revoke_all_tokens,
)
_MAX_NAME_LEN = 255
class TeacherBootstrapError(RuntimeError):
"""Penolakan operasional; pesan aman untuk ditampilkan (tanpa secret)."""
def _teacher_users(db: Session) -> list[User]:
return list(db.scalars(select(User).where(User.role == "teacher")))
def _is_active_token_of(db: Session, user_id: str, raw_token: str) -> bool:
digest = hash_token(raw_token)
token = db.scalar(
select(AccessToken).where(
AccessToken.token_hash == digest,
AccessToken.user_id == user_id,
AccessToken.revoked_at.is_(None),
)
)
return token is not None
def _ensure_token_for_teacher(db: Session, user_id: str, raw_token: str) -> None:
"""Aktifkan token untuk guru; reactivate bila baris digest sudah ada.
Digest bersifat unik (`uq_access_tokens_token_hash`), jadi token yang
pernah di-revoke TIDAK boleh dibuat ulang sebagai baris baru — cukup
re-activate baris lama. Token milik user lain (siswa) sudah ditolak
oleh pemanggil sebelum sampai sini.
"""
digest = hash_token(raw_token)
existing = db.scalar(
select(AccessToken).where(
AccessToken.token_hash == digest,
AccessToken.user_id == user_id,
)
)
if existing is not None:
existing.revoked_at = None
return
create_access_token(db, user_id=user_id, raw_token=raw_token)
def upsert_teacher(db: Session, *, display_name: str, raw_token: str) -> dict:
"""Upsert satu akun guru canonical; commit/rollback dikelola di sini.
Returns dict {"action": "created"|"updated"|"unchanged", "message": str}
— tanpa secret apa pun.
Raises:
TeacherBootstrapError — penolakan dengan pesan aman untuk ditampilkan.
Exception lain — kegagalan tak terduga (sudah di-rollback).
"""
display_name = (display_name or "").strip()
raw_token = (raw_token or "").strip()
if not display_name:
raise TeacherBootstrapError("Nama guru tidak boleh kosong.")
if len(display_name) > _MAX_NAME_LEN:
raise TeacherBootstrapError(
f"Nama guru terlalu panjang (maksimal {_MAX_NAME_LEN} karakter)."
)
if not raw_token:
raise TeacherBootstrapError("Token guru tidak boleh kosong.")
try:
teachers = _teacher_users(db)
if len(teachers) > 1:
raise TeacherBootstrapError(
"Ditemukan lebih dari satu akun guru di database. "
"Normalisasi data secara manual terlebih dahulu; "
"operasi tidak memilih atau menghapus guru secara otomatis."
)
owner = find_user_by_raw_token(db, raw_token)
if owner is not None and owner.role == "student":
raise TeacherBootstrapError(
"Token tersebut sudah dimiliki akun siswa; "
"operasi dibatalkan tanpa mengubah data guru."
)
if not teachers:
user = create_user(db, display_name=display_name, role="teacher")
create_access_token(db, user_id=user.id, raw_token=raw_token)
db.commit()
return {
"action": "created",
"message": "Akun guru dibuat: nama dan token aktif tersimpan.",
}
teacher = teachers[0]
token_changed = not _is_active_token_of(db, teacher.id, raw_token)
name_changed = teacher.display_name != display_name
if name_changed:
teacher.display_name = display_name
if token_changed:
revoke_all_tokens(db, user_id=teacher.id)
_ensure_token_for_teacher(db, user_id=teacher.id, raw_token=raw_token)
db.commit()
if not (token_changed or name_changed):
return {"action": "unchanged", "message": "Data guru tidak berubah."}
if token_changed and name_changed:
return {
"action": "updated",
"message": "Data guru diperbarui: nama baru + token dirotasi.",
}
if token_changed:
return {"action": "updated", "message": "Data guru diperbarui: token dirotasi."}
return {"action": "updated", "message": "Data guru diperbarui: nama baru."}
except TeacherBootstrapError:
if db is not None:
db.rollback()
raise
except Exception:
if db is not None:
db.rollback()
raise