// Package auth provides authentication and authorization for Silo. package auth import ( "context" "fmt" "github.com/rs/zerolog" "github.com/kindredsystems/silo/internal/db" ) // Role constants. const ( RoleAdmin = "admin" RoleEditor = "editor" RoleViewer = "viewer" ) // roleRank maps roles to their privilege level for comparison. var roleRank = map[string]int{ RoleViewer: 1, RoleEditor: 2, RoleAdmin: 3, } // RoleSatisfies returns true if the user's role meets or exceeds the minimum required role. func RoleSatisfies(userRole, minimumRole string) bool { return roleRank[userRole] >= roleRank[minimumRole] } // User represents an authenticated user in the system. type User struct { ID string Username string DisplayName string Email string Role string // "admin", "editor", "viewer" AuthSource string // "local", "ldap", "oidc" } // contextKey is a private type for context keys in this package. type contextKey int const userContextKey contextKey = iota // UserFromContext extracts the authenticated user from the request context. // Returns nil if no user is present (unauthenticated request). func UserFromContext(ctx context.Context) *User { u, _ := ctx.Value(userContextKey).(*User) return u } // ContextWithUser returns a new context carrying the given user. func ContextWithUser(ctx context.Context, u *User) context.Context { return context.WithValue(ctx, userContextKey, u) } // Backend is the interface every auth provider must implement. type Backend interface { // Name returns the backend identifier ("local", "ldap"). Name() string // Authenticate validates credentials and returns the authenticated user. Authenticate(ctx context.Context, username, password string) (*User, error) } // Service orchestrates authentication across all configured backends. type Service struct { users *db.UserRepository tokens *db.TokenRepository backends []Backend logger zerolog.Logger } // NewService creates the auth service with the given backends. func NewService(logger zerolog.Logger, users *db.UserRepository, tokens *db.TokenRepository, backends ...Backend) *Service { return &Service{ users: users, tokens: tokens, backends: backends, logger: logger, } } // Authenticate tries each backend in order until one succeeds. // On success, upserts the user into the local database and updates last_login_at. func (s *Service) Authenticate(ctx context.Context, username, password string) (*User, error) { for _, b := range s.backends { user, err := b.Authenticate(ctx, username, password) if err != nil { s.logger.Debug().Str("backend", b.Name()).Str("username", username).Err(err).Msg("auth attempt failed") continue } // Upsert user into local database dbUser := &db.User{ Username: user.Username, DisplayName: user.DisplayName, Email: user.Email, AuthSource: user.AuthSource, Role: user.Role, } if err := s.users.Upsert(ctx, dbUser); err != nil { return nil, fmt.Errorf("upserting user: %w", err) } user.ID = dbUser.ID s.logger.Info().Str("backend", b.Name()).Str("username", username).Msg("user authenticated") return user, nil } return nil, fmt.Errorf("invalid credentials") } // UpsertOIDCUser upserts a user from OIDC claims into the local database. func (s *Service) UpsertOIDCUser(ctx context.Context, user *User) error { dbUser := &db.User{ Username: user.Username, DisplayName: user.DisplayName, Email: user.Email, AuthSource: "oidc", OIDCSubject: &user.ID, // ID carries the OIDC subject before DB upsert Role: user.Role, } if err := s.users.Upsert(ctx, dbUser); err != nil { return fmt.Errorf("upserting oidc user: %w", err) } user.ID = dbUser.ID return nil } // ValidateToken checks a raw API token and returns the owning user. func (s *Service) ValidateToken(ctx context.Context, rawToken string) (*User, error) { tokenInfo, err := s.tokens.ValidateToken(ctx, rawToken) if err != nil { return nil, err } dbUser, err := s.users.GetByID(ctx, tokenInfo.UserID) if err != nil { return nil, fmt.Errorf("looking up token user: %w", err) } if dbUser == nil || !dbUser.IsActive { return nil, fmt.Errorf("token user not found or inactive") } // Update last_used_at asynchronously go func() { _ = s.tokens.TouchLastUsed(context.Background(), tokenInfo.ID) }() return &User{ ID: dbUser.ID, Username: dbUser.Username, DisplayName: dbUser.DisplayName, Email: dbUser.Email, Role: dbUser.Role, AuthSource: dbUser.AuthSource, }, nil } // GetUserByID retrieves a user by their database ID. func (s *Service) GetUserByID(ctx context.Context, id string) (*User, error) { dbUser, err := s.users.GetByID(ctx, id) if err != nil { return nil, err } if dbUser == nil { return nil, nil } return &User{ ID: dbUser.ID, Username: dbUser.Username, DisplayName: dbUser.DisplayName, Email: dbUser.Email, Role: dbUser.Role, AuthSource: dbUser.AuthSource, }, nil } // Users returns the underlying user repository for direct access. func (s *Service) Users() *db.UserRepository { return s.users } // Tokens returns the underlying token repository for direct access. func (s *Service) Tokens() *db.TokenRepository { return s.tokens }