fetch_ml/internal/auth/permissions.go
Jeremie Fraeys c52179dcbe
feat(auth): add token-based access and structured logging
Add comprehensive authentication and authorization enhancements:

- tokens.go: New token management system for public task access and cloning
  * SHA-256 hashed token storage for security
  * Token generation, validation, and automatic cleanup
  * Support for public access and clone permissions

- api_key.go: Extend User struct with Groups field
  * Lab group membership (ml-lab, nlp-group)
  * Integration with permission system for group-based access

- flags.go: Security hardening - migrate to structured logging
  * Replace log.Printf with log/slog to prevent log injection attacks
  * Consistent structured output for all auth warnings
  * Safe handling of file paths and errors in logs

- permissions.go: Add task sharing permission constants
  * PermissionTasksReadOwn: Access own tasks
  * PermissionTasksReadLab: Access lab group tasks
  * PermissionTasksReadAll: Admin/institution-wide access
  * PermissionTasksShare: Grant access to other users
  * PermissionTasksClone: Create copies of shared tasks
  * CanAccessTask() method with visibility checks

- database.go: Improve error handling
  * Add structured error logging on row close failures
2026-03-08 12:51:07 -04:00

277 lines
7.5 KiB
Go

package auth
import (
"fmt"
"strings"
"github.com/jfraeys/fetch_ml/internal/domain"
)
// Permission constants for type safety
const (
// Job permissions
PermissionJobsCreate = "jobs:create"
PermissionJobsRead = "jobs:read"
PermissionJobsUpdate = "jobs:update"
PermissionJobsDelete = "jobs:delete"
// Data permissions
PermissionDataCreate = "data:create"
PermissionDataRead = "data:read"
PermissionDataUpdate = "data:update"
PermissionDataDelete = "data:delete"
// Model permissions
PermissionModelsCreate = "models:create"
PermissionModelsRead = "models:read"
PermissionModelsUpdate = "models:update"
PermissionModelsDelete = "models:delete"
// System permissions
PermissionSystemConfig = "system:config"
PermissionSystemMetrics = "system:metrics"
PermissionSystemLogs = "system:logs"
PermissionSystemUsers = "system:users"
// Task sharing permissions
PermissionTasksReadOwn = "tasks:read:own"
PermissionTasksReadLab = "tasks:read:lab"
PermissionTasksReadAll = "tasks:read:all"
PermissionTasksShare = "tasks:share"
PermissionTasksClone = "tasks:clone"
// Wildcard permission
PermissionAll = "*"
)
// Role constants
const (
RoleAdmin = "admin"
RoleDataScientist = "data_scientist"
RoleDataEngineer = "data_engineer"
RoleViewer = "viewer"
RoleOperator = "operator"
)
// PermissionGroup represents a group of related permissions
type PermissionGroup struct {
Name string
Description string
Permissions []string
}
// PermissionGroups defines built-in permission groups.
var PermissionGroups = map[string]PermissionGroup{
"full_access": {
Name: "Full Access",
Permissions: []string{PermissionAll},
Description: "Complete system access",
},
"job_management": {
Name: "Job Management",
Permissions: []string{
PermissionJobsCreate,
PermissionJobsRead,
PermissionJobsUpdate,
PermissionJobsDelete,
},
Description: "Create, read, update, and delete ML jobs",
},
"data_access": {
Name: "Data Access",
Permissions: []string{
PermissionDataRead,
PermissionDataCreate,
PermissionDataUpdate,
PermissionDataDelete,
},
Description: "Access and manage datasets",
},
"readonly": {
Name: "Read Only",
Permissions: []string{
PermissionJobsRead,
PermissionDataRead,
PermissionModelsRead,
PermissionSystemMetrics,
},
Description: "View-only access to system resources",
},
"system_admin": {
Name: "System Administration",
Permissions: []string{
PermissionSystemConfig,
PermissionSystemLogs,
PermissionSystemUsers,
PermissionSystemMetrics,
},
Description: "System configuration and user management",
},
}
// GetPermissionGroup returns a permission group by name
func GetPermissionGroup(name string) (PermissionGroup, bool) {
group, exists := PermissionGroups[name]
return group, exists
}
// ValidatePermission checks if a permission string is valid
func ValidatePermission(permission string) error {
if permission == PermissionAll {
return nil
}
// Check if permission matches known patterns
validPrefixes := []string{"jobs:", "data:", "models:", "system:"}
for _, prefix := range validPrefixes {
if strings.HasPrefix(permission, prefix) {
return nil
}
}
return fmt.Errorf("invalid permission format: %s", permission)
}
// ValidateRole checks if a role is valid
func ValidateRole(role string) error {
validRoles := []string{RoleAdmin, RoleDataScientist, RoleDataEngineer, RoleViewer, RoleOperator}
for _, validRole := range validRoles {
if role == validRole {
return nil
}
}
return fmt.Errorf("invalid role: %s", role)
}
// ExpandPermissionGroups converts permission group names to actual permissions
func ExpandPermissionGroups(groups []string) ([]string, error) {
var permissions []string
for _, groupName := range groups {
if groupName == PermissionAll {
return []string{PermissionAll}, nil
}
group, exists := GetPermissionGroup(groupName)
if !exists {
return nil, fmt.Errorf("unknown permission group: %s", groupName)
}
permissions = append(permissions, group.Permissions...)
}
// Remove duplicates
unique := make(map[string]bool)
for _, perm := range permissions {
unique[perm] = true
}
result := make([]string, 0, len(unique))
for perm := range unique {
result = append(result, perm)
}
return result, nil
}
// PermissionCheckResult represents the result of a permission check
type PermissionCheckResult struct {
Permission string `json:"permission"`
User string `json:"user"`
Roles []string `json:"roles"`
Missing []string `json:"missing,omitempty"`
Allowed bool `json:"allowed"`
}
// CheckMultiplePermissions checks multiple permissions at once
func (u *User) CheckMultiplePermissions(permissions []string) []PermissionCheckResult {
results := make([]PermissionCheckResult, len(permissions))
for i, permission := range permissions {
allowed := u.HasPermission(permission)
missing := []string{}
if !allowed {
missing = []string{permission}
}
results[i] = PermissionCheckResult{
Allowed: allowed,
Permission: permission,
User: u.Name,
Roles: u.Roles,
Missing: missing,
}
}
return results
}
// GetEffectivePermissions returns all effective permissions for a user
func (u *User) GetEffectivePermissions() []string {
if u.Permissions[PermissionAll] {
return []string{PermissionAll}
}
permissions := make([]string, 0, len(u.Permissions))
for perm := range u.Permissions {
permissions = append(permissions, perm)
}
return permissions
}
// TaskAccessChecker defines the interface for checking task access permissions.
// Implemented by storage.DB to avoid circular imports.
type TaskAccessChecker interface {
TaskIsSharedWithUser(taskID, userID string) bool
UserSharesGroupWithTask(userID, taskID string) bool
TaskAllowsPublicClone(taskID string) bool
UserRoleInTaskGroups(userID, taskID string) string // NEW: returns highest role (admin/member/viewer)
}
// CanAccessTask checks if an authenticated user can access a specific task.
// For unauthenticated (token-based) access, use CanAccessWithToken() instead.
// NOTE: This method makes 1-2 DB calls per invocation. Never call it in a loop
// over a task list — use ListTasksForUser() instead, which resolves all access
// in a single query.
func (u *User) CanAccessTask(task *domain.Task, db TaskAccessChecker) bool {
if task.UserID == u.Name || task.CreatedBy == u.Name {
return true
}
if u.Admin {
return true
}
// Explicit share — expiry checked in SQL
if db.TaskIsSharedWithUser(task.ID, u.Name) {
return true
}
switch task.Visibility {
case domain.VisibilityPrivate:
return false
case domain.VisibilityLab:
return db.UserSharesGroupWithTask(u.Name, task.ID)
case domain.VisibilityInstitution, domain.VisibilityOpen:
// open tasks are accessible to authenticated users too, not only token holders
return u.HasPermission(PermissionTasksReadLab)
}
return false
}
// CanCloneTask checks if a user can clone (reproduce) a task.
// Clone is a distinct action from read. Viewing a task does not imply
// permission to reproduce it under a different account.
func (u *User) CanCloneTask(task *domain.Task, db TaskAccessChecker) bool {
if !u.CanAccessTask(task, db) {
return false
}
if task.Visibility == domain.VisibilityOpen {
return db.TaskAllowsPublicClone(task.ID)
}
// NEW: viewer-role members can see but not clone
// role == "" means access is via explicit task_shares (not group) - allow clone
role := db.UserRoleInTaskGroups(u.Name, task.ID)
if role == "viewer" {
return false
}
return u.HasPermission(PermissionTasksClone)
}