Initial commit: breakpilot-core - Shared Infrastructure

Docker Compose with 24+ services:
- PostgreSQL (PostGIS), Valkey, MinIO, Qdrant
- Vault (PKI/TLS), Nginx (Reverse Proxy)
- Backend Core API, Consent Service, Billing Service
- RAG Service, Embedding Service
- Gitea, Woodpecker CI/CD
- Night Scheduler, Health Aggregator
- Jitsi (Web/XMPP/JVB/Jicofo), Mailpit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:13 +01:00
commit ad111d5e69
244 changed files with 84288 additions and 0 deletions

View File

@@ -0,0 +1,403 @@
package session
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Employee permissions
var EmployeePermissions = []string{
"grades:read", "grades:write",
"attendance:read", "attendance:write",
"students:read", "students:write",
"reports:generate", "consent:admin",
"corrections:read", "corrections:write",
"classes:read", "classes:write",
"timetable:read", "timetable:write",
"substitutions:read", "substitutions:write",
"parent_communication:read", "parent_communication:write",
}
// Customer permissions
var CustomerPermissions = []string{
"own_data:read", "own_grades:read", "own_attendance:read",
"consent:manage",
"meetings:join",
"messages:read", "messages:write",
"children:read", "children:grades:read", "children:attendance:read",
}
// Admin permissions
var AdminPermissions = []string{
"users:read", "users:write", "users:manage",
"rbac:read", "rbac:write",
"audit:read",
"settings:read", "settings:write",
"dsr:read", "dsr:process",
}
// Employee roles
var EmployeeRoles = map[string]bool{
"admin": true,
"schul_admin": true,
"schulleitung": true,
"pruefungsvorsitz": true,
"klassenlehrer": true,
"fachlehrer": true,
"sekretariat": true,
"erstkorrektor": true,
"zweitkorrektor": true,
"drittkorrektor": true,
"teacher_assistant": true,
"teacher": true,
"lehrer": true,
"data_protection_officer": true,
}
// Customer roles
var CustomerRoles = map[string]bool{
"parent": true,
"student": true,
"user": true,
"guardian": true,
}
// RequireEmployee requires the user to be an employee
func RequireEmployee() gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
if !session.IsEmployee() {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Employee access required",
})
return
}
c.Next()
}
}
// RequireCustomer requires the user to be a customer
func RequireCustomer() gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
if !session.IsCustomer() {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Customer access required",
})
return
}
c.Next()
}
}
// RequireUserType requires a specific user type
func RequireUserType(userType UserType) gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
if session.UserType != userType {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "User type '" + string(userType) + "' required",
})
return
}
c.Next()
}
}
// RequirePermission requires a specific permission
func RequirePermission(permission string) gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
if !session.HasPermission(permission) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Permission '" + permission + "' required",
})
return
}
c.Next()
}
}
// RequireAnyPermission requires at least one of the permissions
func RequireAnyPermission(permissions ...string) gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
if !session.HasAnyPermission(permissions) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "One of the required permissions is missing",
})
return
}
c.Next()
}
}
// RequireAllPermissions requires all specified permissions
func RequireAllPermissions(permissions ...string) gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
if !session.HasAllPermissions(permissions) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Missing required permissions",
})
return
}
c.Next()
}
}
// RequireRole requires a specific role
func RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
if !session.HasRole(role) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Role '" + role + "' required",
})
return
}
c.Next()
}
}
// RequireAnyRole requires at least one of the roles
func RequireAnyRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
for _, role := range roles {
if session.HasRole(role) {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "One of the required roles is missing",
})
}
}
// RequireSameTenant ensures user can only access their tenant's data
func RequireSameTenant(tenantIDParam string) gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
requestTenantID := c.Param(tenantIDParam)
if requestTenantID == "" {
requestTenantID = c.Query(tenantIDParam)
}
if requestTenantID != "" && session.TenantID != nil && *session.TenantID != requestTenantID {
// Check if user is super admin
if !session.HasRole("super_admin") {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "forbidden",
"message": "Access denied to this tenant",
})
return
}
}
c.Next()
}
}
// DetermineUserType determines user type based on roles
func DetermineUserType(roles []string) UserType {
for _, role := range roles {
if EmployeeRoles[role] {
return UserTypeEmployee
}
}
for _, role := range roles {
if CustomerRoles[role] {
return UserTypeCustomer
}
}
return UserTypeCustomer
}
// GetPermissionsForRoles returns permissions based on roles and user type
func GetPermissionsForRoles(roles []string, userType UserType) []string {
permSet := make(map[string]bool)
// Base permissions by user type
if userType == UserTypeEmployee {
for _, p := range EmployeePermissions {
permSet[p] = true
}
} else {
for _, p := range CustomerPermissions {
permSet[p] = true
}
}
// Admin permissions
adminRoles := map[string]bool{
"admin": true,
"schul_admin": true,
"super_admin": true,
"data_protection_officer": true,
}
for _, role := range roles {
if adminRoles[role] {
for _, p := range AdminPermissions {
permSet[p] = true
}
break
}
}
// Convert to slice
permissions := make([]string, 0, len(permSet))
for p := range permSet {
permissions = append(permissions, p)
}
return permissions
}
// CheckResourceOwnership checks if user owns a resource or is admin
func CheckResourceOwnership(session *Session, resourceUserID string, allowAdmin bool) bool {
if session == nil {
return false
}
// User owns the resource
if session.UserID == resourceUserID {
return true
}
// Admin can access all
if allowAdmin && (session.HasRole("admin") || session.HasRole("super_admin")) {
return true
}
return false
}
// IsSessionEmployee checks if current session belongs to an employee
func IsSessionEmployee(c *gin.Context) bool {
session := GetSession(c)
if session == nil {
return false
}
return session.IsEmployee()
}
// IsSessionCustomer checks if current session belongs to a customer
func IsSessionCustomer(c *gin.Context) bool {
session := GetSession(c)
if session == nil {
return false
}
return session.IsCustomer()
}
// HasSessionPermission checks if session has a permission
func HasSessionPermission(c *gin.Context, permission string) bool {
session := GetSession(c)
if session == nil {
return false
}
return session.HasPermission(permission)
}
// HasSessionRole checks if session has a role
func HasSessionRole(c *gin.Context, role string) bool {
session := GetSession(c)
if session == nil {
return false
}
return session.HasRole(role)
}

View File

@@ -0,0 +1,196 @@
package session
import (
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// SessionMiddleware extracts session from request and adds to context
func SessionMiddleware(pgPool *pgxpool.Pool) gin.HandlerFunc {
store := GetSessionStore(pgPool)
return func(c *gin.Context) {
sessionID := extractSessionID(c)
if sessionID != "" {
session, err := store.GetSession(c.Request.Context(), sessionID)
if err == nil && session != nil {
// Add session to context
c.Set("session", session)
c.Set("session_id", session.SessionID)
c.Set("user_id", session.UserID)
c.Set("email", session.Email)
c.Set("user_type", string(session.UserType))
c.Set("roles", session.Roles)
c.Set("permissions", session.Permissions)
if session.TenantID != nil {
c.Set("tenant_id", *session.TenantID)
}
}
}
c.Next()
}
}
// extractSessionID extracts session ID from request
func extractSessionID(c *gin.Context) string {
// Try Authorization header first
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
// Try X-Session-ID header
if sessionID := c.GetHeader("X-Session-ID"); sessionID != "" {
return sessionID
}
// Try cookie
if cookie, err := c.Cookie("session_id"); err == nil {
return cookie
}
return ""
}
// RequireSession requires a valid session
func RequireSession() gin.HandlerFunc {
return func(c *gin.Context) {
session := GetSession(c)
if session == nil {
// Check development bypass
if os.Getenv("ENVIRONMENT") == "development" {
// Set demo session
demoSession := getDemoSession()
c.Set("session", demoSession)
c.Set("session_id", demoSession.SessionID)
c.Set("user_id", demoSession.UserID)
c.Set("email", demoSession.Email)
c.Set("user_type", string(demoSession.UserType))
c.Set("roles", demoSession.Roles)
c.Set("permissions", demoSession.Permissions)
c.Next()
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "unauthorized",
"message": "Authentication required",
})
return
}
c.Next()
}
}
// GetSession retrieves session from context
func GetSession(c *gin.Context) *Session {
if session, exists := c.Get("session"); exists {
if s, ok := session.(*Session); ok {
return s
}
}
return nil
}
// GetSessionUserID retrieves user ID from session context
func GetSessionUserID(c *gin.Context) (uuid.UUID, error) {
userIDStr, exists := c.Get("user_id")
if !exists {
return uuid.Nil, nil
}
return uuid.Parse(userIDStr.(string))
}
// GetSessionEmail retrieves email from session context
func GetSessionEmail(c *gin.Context) string {
email, exists := c.Get("email")
if !exists {
return ""
}
return email.(string)
}
// GetSessionUserType retrieves user type from session context
func GetSessionUserType(c *gin.Context) UserType {
userType, exists := c.Get("user_type")
if !exists {
return UserTypeCustomer
}
return UserType(userType.(string))
}
// GetSessionRoles retrieves roles from session context
func GetSessionRoles(c *gin.Context) []string {
roles, exists := c.Get("roles")
if !exists {
return nil
}
if r, ok := roles.([]string); ok {
return r
}
return nil
}
// GetSessionPermissions retrieves permissions from session context
func GetSessionPermissions(c *gin.Context) []string {
perms, exists := c.Get("permissions")
if !exists {
return nil
}
if p, ok := perms.([]string); ok {
return p
}
return nil
}
// GetSessionTenantID retrieves tenant ID from session context
func GetSessionTenantID(c *gin.Context) *string {
tenantID, exists := c.Get("tenant_id")
if !exists {
return nil
}
if t, ok := tenantID.(string); ok {
return &t
}
return nil
}
// getDemoSession returns a demo session for development
func getDemoSession() *Session {
tenantID := "a0000000-0000-0000-0000-000000000001"
ip := "127.0.0.1"
ua := "Development"
return &Session{
SessionID: "demo-session-id",
UserID: "10000000-0000-0000-0000-000000000024",
Email: "demo@breakpilot.app",
UserType: UserTypeEmployee,
Roles: []string{
"admin", "schul_admin", "teacher",
},
Permissions: []string{
"grades:read", "grades:write",
"attendance:read", "attendance:write",
"students:read", "students:write",
"reports:generate", "consent:admin",
"own_data:read", "users:manage",
},
TenantID: &tenantID,
IPAddress: &ip,
UserAgent: &ua,
CreatedAt: time.Now().UTC(),
LastActivityAt: time.Now().UTC(),
}
}

View File

@@ -0,0 +1,463 @@
package session
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
// UserType distinguishes between internal employees and external customers
type UserType string
const (
UserTypeEmployee UserType = "employee"
UserTypeCustomer UserType = "customer"
)
// Session represents a user session with RBAC data
type Session struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Email string `json:"email"`
UserType UserType `json:"user_type"`
Roles []string `json:"roles"`
Permissions []string `json:"permissions"`
TenantID *string `json:"tenant_id,omitempty"`
IPAddress *string `json:"ip_address,omitempty"`
UserAgent *string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastActivityAt time.Time `json:"last_activity_at"`
}
// HasPermission checks if session has a specific permission
func (s *Session) HasPermission(permission string) bool {
for _, p := range s.Permissions {
if p == permission {
return true
}
}
return false
}
// HasAnyPermission checks if session has any of the specified permissions
func (s *Session) HasAnyPermission(permissions []string) bool {
for _, needed := range permissions {
for _, has := range s.Permissions {
if needed == has {
return true
}
}
}
return false
}
// HasAllPermissions checks if session has all specified permissions
func (s *Session) HasAllPermissions(permissions []string) bool {
for _, needed := range permissions {
found := false
for _, has := range s.Permissions {
if needed == has {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// HasRole checks if session has a specific role
func (s *Session) HasRole(role string) bool {
for _, r := range s.Roles {
if r == role {
return true
}
}
return false
}
// IsEmployee checks if user is an employee (internal staff)
func (s *Session) IsEmployee() bool {
return s.UserType == UserTypeEmployee
}
// IsCustomer checks if user is a customer (external user)
func (s *Session) IsCustomer() bool {
return s.UserType == UserTypeCustomer
}
// SessionStore provides hybrid Valkey + PostgreSQL session storage
type SessionStore struct {
valkeyClient *redis.Client
pgPool *pgxpool.Pool
sessionTTL time.Duration
valkeyEnabled bool
mu sync.RWMutex
}
// NewSessionStore creates a new session store
func NewSessionStore(pgPool *pgxpool.Pool) *SessionStore {
ttlHours := 24
if ttlStr := os.Getenv("SESSION_TTL_HOURS"); ttlStr != "" {
if val, err := strconv.Atoi(ttlStr); err == nil {
ttlHours = val
}
}
store := &SessionStore{
pgPool: pgPool,
sessionTTL: time.Duration(ttlHours) * time.Hour,
valkeyEnabled: false,
}
// Try to connect to Valkey
valkeyURL := os.Getenv("VALKEY_URL")
if valkeyURL == "" {
valkeyURL = "redis://localhost:6379"
}
opt, err := redis.ParseURL(valkeyURL)
if err == nil {
store.valkeyClient = redis.NewClient(opt)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := store.valkeyClient.Ping(ctx).Err(); err == nil {
store.valkeyEnabled = true
}
}
return store
}
// Close closes all connections
func (s *SessionStore) Close() {
if s.valkeyClient != nil {
s.valkeyClient.Close()
}
}
// getValkeyKey returns the Valkey key for a session
func (s *SessionStore) getValkeyKey(sessionID string) string {
return fmt.Sprintf("session:%s", sessionID)
}
// CreateSession creates a new session
func (s *SessionStore) CreateSession(ctx context.Context, userID, email string, userType UserType, roles, permissions []string, tenantID, ipAddress, userAgent *string) (*Session, error) {
session := &Session{
SessionID: uuid.New().String(),
UserID: userID,
Email: email,
UserType: userType,
Roles: roles,
Permissions: permissions,
TenantID: tenantID,
IPAddress: ipAddress,
UserAgent: userAgent,
CreatedAt: time.Now().UTC(),
LastActivityAt: time.Now().UTC(),
}
// Store in Valkey (primary cache)
if s.valkeyEnabled {
data, err := json.Marshal(session)
if err == nil {
key := s.getValkeyKey(session.SessionID)
s.valkeyClient.SetEx(ctx, key, data, s.sessionTTL)
}
}
// Store in PostgreSQL (persistent + audit)
if s.pgPool != nil {
rolesJSON, _ := json.Marshal(roles)
permsJSON, _ := json.Marshal(permissions)
expiresAt := time.Now().UTC().Add(s.sessionTTL)
_, err := s.pgPool.Exec(ctx, `
INSERT INTO user_sessions (
id, user_id, token_hash, email, user_type, roles,
permissions, tenant_id, ip_address, user_agent,
expires_at, created_at, last_activity_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`,
session.SessionID,
session.UserID,
session.SessionID, // token_hash = session_id for session-based auth
session.Email,
string(session.UserType),
rolesJSON,
permsJSON,
tenantID,
ipAddress,
userAgent,
expiresAt,
session.CreatedAt,
session.LastActivityAt,
)
if err != nil {
return nil, fmt.Errorf("failed to store session in PostgreSQL: %w", err)
}
}
return session, nil
}
// GetSession retrieves a session by ID
func (s *SessionStore) GetSession(ctx context.Context, sessionID string) (*Session, error) {
// Try Valkey first
if s.valkeyEnabled {
key := s.getValkeyKey(sessionID)
data, err := s.valkeyClient.Get(ctx, key).Bytes()
if err == nil {
var session Session
if err := json.Unmarshal(data, &session); err == nil {
// Update last activity
go s.updateLastActivity(sessionID)
return &session, nil
}
}
}
// Fallback to PostgreSQL
if s.pgPool != nil {
var session Session
var rolesJSON, permsJSON []byte
var tenantID, ipAddress, userAgent *string
err := s.pgPool.QueryRow(ctx, `
SELECT id, user_id, email, user_type, roles, permissions,
tenant_id, ip_address, user_agent, created_at, last_activity_at
FROM user_sessions
WHERE id = $1
AND revoked_at IS NULL
AND expires_at > NOW()
`, sessionID).Scan(
&session.SessionID,
&session.UserID,
&session.Email,
&session.UserType,
&rolesJSON,
&permsJSON,
&tenantID,
&ipAddress,
&userAgent,
&session.CreatedAt,
&session.LastActivityAt,
)
if err != nil {
return nil, errors.New("session not found or expired")
}
json.Unmarshal(rolesJSON, &session.Roles)
json.Unmarshal(permsJSON, &session.Permissions)
session.TenantID = tenantID
session.IPAddress = ipAddress
session.UserAgent = userAgent
// Re-cache in Valkey
if s.valkeyEnabled {
data, _ := json.Marshal(session)
key := s.getValkeyKey(sessionID)
s.valkeyClient.SetEx(ctx, key, data, s.sessionTTL)
}
return &session, nil
}
return nil, errors.New("session not found")
}
// updateLastActivity updates the last activity timestamp
func (s *SessionStore) updateLastActivity(sessionID string) {
ctx := context.Background()
now := time.Now().UTC()
// Update Valkey TTL
if s.valkeyEnabled {
key := s.getValkeyKey(sessionID)
s.valkeyClient.Expire(ctx, key, s.sessionTTL)
}
// Update PostgreSQL
if s.pgPool != nil {
s.pgPool.Exec(ctx, `
UPDATE user_sessions
SET last_activity_at = $1, expires_at = $2
WHERE id = $3
`, now, now.Add(s.sessionTTL), sessionID)
}
}
// RevokeSession revokes a session (logout)
func (s *SessionStore) RevokeSession(ctx context.Context, sessionID string) error {
// Remove from Valkey
if s.valkeyEnabled {
key := s.getValkeyKey(sessionID)
s.valkeyClient.Del(ctx, key)
}
// Mark as revoked in PostgreSQL
if s.pgPool != nil {
_, err := s.pgPool.Exec(ctx, `
UPDATE user_sessions
SET revoked_at = NOW()
WHERE id = $1
`, sessionID)
if err != nil {
return fmt.Errorf("failed to revoke session: %w", err)
}
}
return nil
}
// RevokeAllUserSessions revokes all sessions for a user
func (s *SessionStore) RevokeAllUserSessions(ctx context.Context, userID string) (int, error) {
if s.pgPool == nil {
return 0, nil
}
// Get all session IDs
rows, err := s.pgPool.Query(ctx, `
SELECT id FROM user_sessions
WHERE user_id = $1
AND revoked_at IS NULL
AND expires_at > NOW()
`, userID)
if err != nil {
return 0, err
}
defer rows.Close()
var sessionIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err == nil {
sessionIDs = append(sessionIDs, id)
}
}
// Revoke in PostgreSQL
result, err := s.pgPool.Exec(ctx, `
UPDATE user_sessions
SET revoked_at = NOW()
WHERE user_id = $1 AND revoked_at IS NULL
`, userID)
if err != nil {
return 0, err
}
// Remove from Valkey
if s.valkeyEnabled {
for _, sessionID := range sessionIDs {
key := s.getValkeyKey(sessionID)
s.valkeyClient.Del(ctx, key)
}
}
return int(result.RowsAffected()), nil
}
// GetActiveSessions returns all active sessions for a user
func (s *SessionStore) GetActiveSessions(ctx context.Context, userID string) ([]*Session, error) {
if s.pgPool == nil {
return nil, nil
}
rows, err := s.pgPool.Query(ctx, `
SELECT id, user_id, email, user_type, roles, permissions,
tenant_id, ip_address, user_agent, created_at, last_activity_at
FROM user_sessions
WHERE user_id = $1
AND revoked_at IS NULL
AND expires_at > NOW()
ORDER BY last_activity_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []*Session
for rows.Next() {
var session Session
var rolesJSON, permsJSON []byte
var tenantID, ipAddress, userAgent *string
err := rows.Scan(
&session.SessionID,
&session.UserID,
&session.Email,
&session.UserType,
&rolesJSON,
&permsJSON,
&tenantID,
&ipAddress,
&userAgent,
&session.CreatedAt,
&session.LastActivityAt,
)
if err != nil {
continue
}
json.Unmarshal(rolesJSON, &session.Roles)
json.Unmarshal(permsJSON, &session.Permissions)
session.TenantID = tenantID
session.IPAddress = ipAddress
session.UserAgent = userAgent
sessions = append(sessions, &session)
}
return sessions, nil
}
// CleanupExpiredSessions removes old expired sessions from PostgreSQL
func (s *SessionStore) CleanupExpiredSessions(ctx context.Context) (int, error) {
if s.pgPool == nil {
return 0, nil
}
result, err := s.pgPool.Exec(ctx, `
DELETE FROM user_sessions
WHERE expires_at < NOW() - INTERVAL '7 days'
`)
if err != nil {
return 0, err
}
return int(result.RowsAffected()), nil
}
// Global session store instance
var (
globalStore *SessionStore
globalStoreMu sync.Mutex
globalStoreOnce sync.Once
)
// GetSessionStore returns the global session store instance
func GetSessionStore(pgPool *pgxpool.Pool) *SessionStore {
globalStoreMu.Lock()
defer globalStoreMu.Unlock()
if globalStore == nil {
globalStore = NewSessionStore(pgPool)
}
return globalStore
}

View File

@@ -0,0 +1,342 @@
package session
import (
"testing"
"time"
)
func TestSessionHasPermission(t *testing.T) {
session := &Session{
SessionID: "test-session-id",
UserID: "test-user-id",
Email: "test@example.com",
UserType: UserTypeEmployee,
Permissions: []string{"grades:read", "grades:write", "attendance:read"},
}
tests := []struct {
name string
permission string
expected bool
}{
{"has grades:read", "grades:read", true},
{"has grades:write", "grades:write", true},
{"missing users:manage", "users:manage", false},
{"empty permission", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := session.HasPermission(tt.permission)
if result != tt.expected {
t.Errorf("HasPermission(%q) = %v, want %v", tt.permission, result, tt.expected)
}
})
}
}
func TestSessionHasAnyPermission(t *testing.T) {
session := &Session{
SessionID: "test-session-id",
UserID: "test-user-id",
Email: "test@example.com",
UserType: UserTypeEmployee,
Permissions: []string{"grades:read"},
}
tests := []struct {
name string
permissions []string
expected bool
}{
{"has one of the permissions", []string{"grades:read", "grades:write"}, true},
{"missing all permissions", []string{"users:manage", "audit:read"}, false},
{"empty list", []string{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := session.HasAnyPermission(tt.permissions)
if result != tt.expected {
t.Errorf("HasAnyPermission(%v) = %v, want %v", tt.permissions, result, tt.expected)
}
})
}
}
func TestSessionHasAllPermissions(t *testing.T) {
session := &Session{
SessionID: "test-session-id",
UserID: "test-user-id",
Email: "test@example.com",
UserType: UserTypeEmployee,
Permissions: []string{"grades:read", "grades:write", "attendance:read"},
}
tests := []struct {
name string
permissions []string
expected bool
}{
{"has all permissions", []string{"grades:read", "grades:write"}, true},
{"missing one permission", []string{"grades:read", "users:manage"}, false},
{"empty list", []string{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := session.HasAllPermissions(tt.permissions)
if result != tt.expected {
t.Errorf("HasAllPermissions(%v) = %v, want %v", tt.permissions, result, tt.expected)
}
})
}
}
func TestSessionHasRole(t *testing.T) {
session := &Session{
SessionID: "test-session-id",
UserID: "test-user-id",
Email: "test@example.com",
UserType: UserTypeEmployee,
Roles: []string{"teacher", "klassenlehrer"},
}
tests := []struct {
name string
role string
expected bool
}{
{"has teacher role", "teacher", true},
{"has klassenlehrer role", "klassenlehrer", true},
{"missing admin role", "admin", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := session.HasRole(tt.role)
if result != tt.expected {
t.Errorf("HasRole(%q) = %v, want %v", tt.role, result, tt.expected)
}
})
}
}
func TestSessionIsEmployee(t *testing.T) {
employeeSession := &Session{
SessionID: "test",
UserID: "test",
Email: "test@test.com",
UserType: UserTypeEmployee,
}
customerSession := &Session{
SessionID: "test",
UserID: "test",
Email: "test@test.com",
UserType: UserTypeCustomer,
}
if !employeeSession.IsEmployee() {
t.Error("Employee session should return true for IsEmployee()")
}
if employeeSession.IsCustomer() {
t.Error("Employee session should return false for IsCustomer()")
}
if !customerSession.IsCustomer() {
t.Error("Customer session should return true for IsCustomer()")
}
if customerSession.IsEmployee() {
t.Error("Customer session should return false for IsEmployee()")
}
}
func TestDetermineUserType(t *testing.T) {
tests := []struct {
name string
roles []string
expected UserType
}{
{"teacher is employee", []string{"teacher"}, UserTypeEmployee},
{"admin is employee", []string{"admin"}, UserTypeEmployee},
{"klassenlehrer is employee", []string{"klassenlehrer"}, UserTypeEmployee},
{"parent is customer", []string{"parent"}, UserTypeCustomer},
{"student is customer", []string{"student"}, UserTypeCustomer},
{"employee takes precedence", []string{"teacher", "parent"}, UserTypeEmployee},
{"unknown role defaults to customer", []string{"unknown_role"}, UserTypeCustomer},
{"empty roles defaults to customer", []string{}, UserTypeCustomer},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := DetermineUserType(tt.roles)
if result != tt.expected {
t.Errorf("DetermineUserType(%v) = %v, want %v", tt.roles, result, tt.expected)
}
})
}
}
func TestGetPermissionsForRoles(t *testing.T) {
t.Run("employee gets employee permissions", func(t *testing.T) {
permissions := GetPermissionsForRoles([]string{"teacher"}, UserTypeEmployee)
hasGradesRead := false
for _, p := range permissions {
if p == "grades:read" {
hasGradesRead = true
break
}
}
if !hasGradesRead {
t.Error("Employee should have grades:read permission")
}
})
t.Run("customer gets customer permissions", func(t *testing.T) {
permissions := GetPermissionsForRoles([]string{"parent"}, UserTypeCustomer)
hasChildrenRead := false
for _, p := range permissions {
if p == "children:read" {
hasChildrenRead = true
break
}
}
if !hasChildrenRead {
t.Error("Customer should have children:read permission")
}
})
t.Run("admin gets admin permissions", func(t *testing.T) {
permissions := GetPermissionsForRoles([]string{"admin"}, UserTypeEmployee)
hasUsersManage := false
for _, p := range permissions {
if p == "users:manage" {
hasUsersManage = true
break
}
}
if !hasUsersManage {
t.Error("Admin should have users:manage permission")
}
})
}
func TestCheckResourceOwnership(t *testing.T) {
userID := "user-123"
adminSession := &Session{
SessionID: "test",
UserID: "admin-456",
Email: "admin@test.com",
UserType: UserTypeEmployee,
Roles: []string{"admin"},
}
regularSession := &Session{
SessionID: "test",
UserID: userID,
Email: "user@test.com",
UserType: UserTypeEmployee,
Roles: []string{"teacher"},
}
otherSession := &Session{
SessionID: "test",
UserID: "other-789",
Email: "other@test.com",
UserType: UserTypeEmployee,
Roles: []string{"teacher"},
}
tests := []struct {
name string
session *Session
resourceUID string
allowAdmin bool
expected bool
}{
{"owner can access", regularSession, userID, true, true},
{"admin can access with allowAdmin", adminSession, userID, true, true},
{"admin cannot access without allowAdmin", adminSession, userID, false, false},
{"other user cannot access", otherSession, userID, true, false},
{"nil session returns false", nil, userID, true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CheckResourceOwnership(tt.session, tt.resourceUID, tt.allowAdmin)
if result != tt.expected {
t.Errorf("CheckResourceOwnership() = %v, want %v", result, tt.expected)
}
})
}
}
func TestEmployeeRolesMap(t *testing.T) {
expectedRoles := []string{
"admin", "schul_admin", "teacher", "klassenlehrer",
"fachlehrer", "sekretariat", "data_protection_officer",
}
for _, role := range expectedRoles {
if !EmployeeRoles[role] {
t.Errorf("Expected employee role %q not found in EmployeeRoles map", role)
}
}
}
func TestCustomerRolesMap(t *testing.T) {
expectedRoles := []string{"parent", "student", "user"}
for _, role := range expectedRoles {
if !CustomerRoles[role] {
t.Errorf("Expected customer role %q not found in CustomerRoles map", role)
}
}
}
func TestPermissionSlicesNotEmpty(t *testing.T) {
if len(EmployeePermissions) == 0 {
t.Error("EmployeePermissions should not be empty")
}
if len(CustomerPermissions) == 0 {
t.Error("CustomerPermissions should not be empty")
}
if len(AdminPermissions) == 0 {
t.Error("AdminPermissions should not be empty")
}
}
func TestSessionTimestamps(t *testing.T) {
now := time.Now().UTC()
session := &Session{
SessionID: "test",
UserID: "test",
Email: "test@test.com",
UserType: UserTypeEmployee,
CreatedAt: now,
LastActivityAt: now,
}
if session.CreatedAt.IsZero() {
t.Error("CreatedAt should not be zero")
}
if session.LastActivityAt.IsZero() {
t.Error("LastActivityAt should not be zero")
}
if session.CreatedAt.After(time.Now().UTC()) {
t.Error("CreatedAt should not be in the future")
}
}
func TestUserTypeConstants(t *testing.T) {
if UserTypeEmployee != "employee" {
t.Errorf("UserTypeEmployee = %q, want %q", UserTypeEmployee, "employee")
}
if UserTypeCustomer != "customer" {
t.Errorf("UserTypeCustomer = %q, want %q", UserTypeCustomer, "customer")
}
}