[split-required] [guardrail-change] Enforce 500 LOC budget across all services
Install LOC guardrails (check-loc.sh, architecture.md, pre-commit hook) and split all 44 files exceeding 500 LOC into domain-focused modules: - consent-service (Go): models, handlers, services, database splits - backend-core (Python): security_api, rbac_api, pdf_service, auth splits - admin-core (TypeScript): 5 page.tsx + sidebar extractions - pitch-deck (TypeScript): 6 slides, 3 UI components, engine.ts splits - voice-service (Python): enhanced_task_orchestrator split Result: 0 violations, 36 exempted (pipeline, tests, pure-data files). Go build verified clean. No behavior changes — pure structural splits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
RBAC API - Teacher and Role Management Endpoints
|
||||
RBAC API - Role and Assignment Management Endpoints
|
||||
|
||||
Provides API endpoints for:
|
||||
- Listing all teachers
|
||||
- Listing all available roles
|
||||
- Assigning/revoking roles to teachers
|
||||
- Viewing role assignments per teacher
|
||||
- Listing all available roles (built-in + custom)
|
||||
- Assigning/revoking roles to users
|
||||
- Role summary with assignment counts
|
||||
- Custom role CRUD
|
||||
|
||||
Shared infrastructure (DB pool, Pydantic models, role definitions)
|
||||
used by rbac_teachers_api.py as well.
|
||||
|
||||
Architecture:
|
||||
- Authentication: Keycloak (when configured) or local JWT
|
||||
@@ -230,163 +233,6 @@ async def list_available_roles() -> List[RoleInfo]:
|
||||
]
|
||||
|
||||
|
||||
@router.get("/teachers")
|
||||
async def list_teachers(user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
||||
"""List all teachers with their current roles"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Get all teachers with their user info
|
||||
teachers = await conn.fetch("""
|
||||
SELECT
|
||||
t.id, t.user_id, t.teacher_code, t.title,
|
||||
t.first_name, t.last_name, t.is_active,
|
||||
u.email, u.name
|
||||
FROM teachers t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
ORDER BY t.last_name, t.first_name
|
||||
""")
|
||||
|
||||
# Get role assignments for all teachers
|
||||
role_assignments = await conn.fetch("""
|
||||
SELECT user_id, role
|
||||
FROM role_assignments
|
||||
WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > NOW())
|
||||
""")
|
||||
|
||||
# Build role lookup
|
||||
role_lookup: Dict[str, List[str]] = {}
|
||||
for ra in role_assignments:
|
||||
uid = str(ra["user_id"])
|
||||
if uid not in role_lookup:
|
||||
role_lookup[uid] = []
|
||||
role_lookup[uid].append(ra["role"])
|
||||
|
||||
# Build response
|
||||
result = []
|
||||
for t in teachers:
|
||||
uid = str(t["user_id"])
|
||||
result.append(TeacherResponse(
|
||||
id=str(t["id"]),
|
||||
user_id=uid,
|
||||
email=t["email"],
|
||||
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
||||
teacher_code=t["teacher_code"],
|
||||
title=t["title"],
|
||||
first_name=t["first_name"],
|
||||
last_name=t["last_name"],
|
||||
is_active=t["is_active"],
|
||||
roles=role_lookup.get(uid, [])
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/teachers/{teacher_id}/roles")
|
||||
async def get_teacher_roles(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleAssignmentResponse]:
|
||||
"""Get all role assignments for a specific teacher"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Get teacher's user_id
|
||||
teacher = await conn.fetchrow(
|
||||
"SELECT user_id FROM teachers WHERE id = $1",
|
||||
teacher_id
|
||||
)
|
||||
if not teacher:
|
||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||
|
||||
# Get role assignments
|
||||
assignments = await conn.fetch("""
|
||||
SELECT id, user_id, role, resource_type, resource_id,
|
||||
valid_from, valid_to, granted_at, revoked_at
|
||||
FROM role_assignments
|
||||
WHERE user_id = $1
|
||||
ORDER BY granted_at DESC
|
||||
""", teacher["user_id"])
|
||||
|
||||
return [
|
||||
RoleAssignmentResponse(
|
||||
id=str(a["id"]),
|
||||
user_id=str(a["user_id"]),
|
||||
role=a["role"],
|
||||
resource_type=a["resource_type"],
|
||||
resource_id=str(a["resource_id"]),
|
||||
valid_from=a["valid_from"].isoformat() if a["valid_from"] else None,
|
||||
valid_to=a["valid_to"].isoformat() if a["valid_to"] else None,
|
||||
granted_at=a["granted_at"].isoformat() if a["granted_at"] else None,
|
||||
is_active=a["revoked_at"] is None and (
|
||||
a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc)
|
||||
)
|
||||
)
|
||||
for a in assignments
|
||||
]
|
||||
|
||||
|
||||
@router.get("/roles/{role}/teachers")
|
||||
async def get_teachers_by_role(role: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]:
|
||||
"""Get all teachers with a specific role"""
|
||||
if role not in AVAILABLE_ROLES:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown role: {role}")
|
||||
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
teachers = await conn.fetch("""
|
||||
SELECT DISTINCT
|
||||
t.id, t.user_id, t.teacher_code, t.title,
|
||||
t.first_name, t.last_name, t.is_active,
|
||||
u.email, u.name
|
||||
FROM teachers t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
JOIN role_assignments ra ON t.user_id = ra.user_id
|
||||
WHERE ra.role = $1
|
||||
AND ra.revoked_at IS NULL
|
||||
AND (ra.valid_to IS NULL OR ra.valid_to > NOW())
|
||||
AND t.school_id = 'a0000000-0000-0000-0000-000000000001'
|
||||
ORDER BY t.last_name, t.first_name
|
||||
""", role)
|
||||
|
||||
# Get all roles for these teachers
|
||||
if teachers:
|
||||
user_ids = [t["user_id"] for t in teachers]
|
||||
role_assignments = await conn.fetch("""
|
||||
SELECT user_id, role
|
||||
FROM role_assignments
|
||||
WHERE user_id = ANY($1)
|
||||
AND revoked_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > NOW())
|
||||
""", user_ids)
|
||||
|
||||
role_lookup: Dict[str, List[str]] = {}
|
||||
for ra in role_assignments:
|
||||
uid = str(ra["user_id"])
|
||||
if uid not in role_lookup:
|
||||
role_lookup[uid] = []
|
||||
role_lookup[uid].append(ra["role"])
|
||||
else:
|
||||
role_lookup = {}
|
||||
|
||||
return [
|
||||
TeacherResponse(
|
||||
id=str(t["id"]),
|
||||
user_id=str(t["user_id"]),
|
||||
email=t["email"],
|
||||
name=t["name"] or f"{t['first_name']} {t['last_name']}",
|
||||
teacher_code=t["teacher_code"],
|
||||
title=t["title"],
|
||||
first_name=t["first_name"],
|
||||
last_name=t["last_name"],
|
||||
is_active=t["is_active"],
|
||||
roles=role_lookup.get(str(t["user_id"]), [])
|
||||
)
|
||||
for t in teachers
|
||||
]
|
||||
|
||||
|
||||
@router.post("/assignments")
|
||||
async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse:
|
||||
"""Assign a role to a user"""
|
||||
@@ -519,178 +365,6 @@ async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) ->
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# TEACHER MANAGEMENT ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
@router.post("/teachers")
|
||||
async def create_teacher(teacher: TeacherCreate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
||||
"""Create a new teacher with optional initial roles"""
|
||||
pool = await get_pool()
|
||||
|
||||
import uuid
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Check if email already exists
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM users WHERE email = $1",
|
||||
teacher.email
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="Email already exists")
|
||||
|
||||
# Generate UUIDs
|
||||
user_id = str(uuid.uuid4())
|
||||
teacher_id = str(uuid.uuid4())
|
||||
|
||||
# Create user first
|
||||
await conn.execute("""
|
||||
INSERT INTO users (id, email, name, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, '', 'teacher', true)
|
||||
""", user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}")
|
||||
|
||||
# Create teacher record
|
||||
await conn.execute("""
|
||||
INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active)
|
||||
VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true)
|
||||
""", teacher_id, user_id, teacher.first_name, teacher.last_name,
|
||||
teacher.teacher_code, teacher.title)
|
||||
|
||||
# Assign initial roles if provided
|
||||
assigned_roles = []
|
||||
for role in teacher.roles:
|
||||
if role in AVAILABLE_ROLES or await conn.fetchrow(
|
||||
"SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role
|
||||
):
|
||||
await conn.execute("""
|
||||
INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by)
|
||||
VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001',
|
||||
'a0000000-0000-0000-0000-000000000001', $3)
|
||||
""", user_id, role, user.get("user_id"))
|
||||
assigned_roles.append(role)
|
||||
|
||||
return TeacherResponse(
|
||||
id=teacher_id,
|
||||
user_id=user_id,
|
||||
email=teacher.email,
|
||||
name=f"{teacher.first_name} {teacher.last_name}",
|
||||
teacher_code=teacher.teacher_code,
|
||||
title=teacher.title,
|
||||
first_name=teacher.first_name,
|
||||
last_name=teacher.last_name,
|
||||
is_active=True,
|
||||
roles=assigned_roles
|
||||
)
|
||||
|
||||
|
||||
@router.put("/teachers/{teacher_id}")
|
||||
async def update_teacher(teacher_id: str, updates: TeacherUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse:
|
||||
"""Update teacher information"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Get current teacher data
|
||||
teacher = await conn.fetchrow("""
|
||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||
u.email, u.name
|
||||
FROM teachers t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.id = $1
|
||||
""", teacher_id)
|
||||
|
||||
if not teacher:
|
||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||
|
||||
# Build update queries
|
||||
if updates.email:
|
||||
await conn.execute("UPDATE users SET email = $1 WHERE id = $2",
|
||||
updates.email, teacher["user_id"])
|
||||
|
||||
teacher_updates = []
|
||||
teacher_values = []
|
||||
idx = 1
|
||||
|
||||
if updates.first_name:
|
||||
teacher_updates.append(f"first_name = ${idx}")
|
||||
teacher_values.append(updates.first_name)
|
||||
idx += 1
|
||||
if updates.last_name:
|
||||
teacher_updates.append(f"last_name = ${idx}")
|
||||
teacher_values.append(updates.last_name)
|
||||
idx += 1
|
||||
if updates.teacher_code is not None:
|
||||
teacher_updates.append(f"teacher_code = ${idx}")
|
||||
teacher_values.append(updates.teacher_code)
|
||||
idx += 1
|
||||
if updates.title is not None:
|
||||
teacher_updates.append(f"title = ${idx}")
|
||||
teacher_values.append(updates.title)
|
||||
idx += 1
|
||||
if updates.is_active is not None:
|
||||
teacher_updates.append(f"is_active = ${idx}")
|
||||
teacher_values.append(updates.is_active)
|
||||
idx += 1
|
||||
|
||||
if teacher_updates:
|
||||
teacher_values.append(teacher_id)
|
||||
await conn.execute(
|
||||
f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}",
|
||||
*teacher_values
|
||||
)
|
||||
|
||||
# Update user name if first/last name changed
|
||||
if updates.first_name or updates.last_name:
|
||||
new_first = updates.first_name or teacher["first_name"]
|
||||
new_last = updates.last_name or teacher["last_name"]
|
||||
await conn.execute("UPDATE users SET name = $1 WHERE id = $2",
|
||||
f"{new_first} {new_last}", teacher["user_id"])
|
||||
|
||||
# Fetch updated data
|
||||
updated = await conn.fetchrow("""
|
||||
SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active,
|
||||
u.email, u.name
|
||||
FROM teachers t
|
||||
JOIN users u ON t.user_id = u.id
|
||||
WHERE t.id = $1
|
||||
""", teacher_id)
|
||||
|
||||
# Get roles
|
||||
roles = await conn.fetch("""
|
||||
SELECT role FROM role_assignments
|
||||
WHERE user_id = $1 AND revoked_at IS NULL
|
||||
AND (valid_to IS NULL OR valid_to > NOW())
|
||||
""", updated["user_id"])
|
||||
|
||||
return TeacherResponse(
|
||||
id=str(updated["id"]),
|
||||
user_id=str(updated["user_id"]),
|
||||
email=updated["email"],
|
||||
name=updated["name"],
|
||||
teacher_code=updated["teacher_code"],
|
||||
title=updated["title"],
|
||||
first_name=updated["first_name"],
|
||||
last_name=updated["last_name"],
|
||||
is_active=updated["is_active"],
|
||||
roles=[r["role"] for r in roles]
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/teachers/{teacher_id}")
|
||||
async def deactivate_teacher(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)):
|
||||
"""Deactivate a teacher (soft delete)"""
|
||||
pool = await get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute("""
|
||||
UPDATE teachers SET is_active = false WHERE id = $1
|
||||
""", teacher_id)
|
||||
|
||||
if result == "UPDATE 0":
|
||||
raise HTTPException(status_code=404, detail="Teacher not found")
|
||||
|
||||
return {"status": "deactivated", "teacher_id": teacher_id}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# CUSTOM ROLE MANAGEMENT ENDPOINTS
|
||||
# ==========================================
|
||||
|
||||
Reference in New Issue
Block a user