diff --git a/go.mod b/go.mod index 56d15cc..0826c60 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 26ce04b..63c6104 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/internal/api/handlers/auth.go b/internal/api/handlers/auth.go new file mode 100644 index 0000000..bf60a5b --- /dev/null +++ b/internal/api/handlers/auth.go @@ -0,0 +1,175 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +package handlers + +import ( + "database/sql" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" + + "archebase.com/keystone-edge/internal/auth" + "archebase.com/keystone-edge/internal/config" +) + +// AuthHandler provides authentication-related HTTP handlers. +type AuthHandler struct { + db *sqlx.DB + cfg *config.AuthConfig +} + +// NewAuthHandler constructs an AuthHandler with required dependencies. +func NewAuthHandler(db *sqlx.DB, cfg *config.AuthConfig) *AuthHandler { + return &AuthHandler{db: db, cfg: cfg} +} + +// CollectorLoginRequest is the request body for collector login. +type CollectorLoginRequest struct { + OperatorID string `json:"operator_id" binding:"required"` + Password string `json:"password" binding:"required"` // #nosec G117 -- request DTO intentionally contains password +} + +// CollectorLoginResponse is the response body returned after a successful collector login. +type CollectorLoginResponse struct { + AccessToken string `json:"access_token"` // #nosec G117 -- response DTO intentionally returns access token + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Collector struct { + ID string `json:"id"` + OperatorID string `json:"operator_id"` + Name string `json:"name"` + } `json:"collector"` +} + +type collectorAuthRow struct { + ID int64 `db:"id"` + Name string `db:"name"` + OperatorID string `db:"operator_id"` + PasswordHash sql.NullString `db:"password_hash"` +} + +// RegisterRoutes registers auth endpoints under the provided router group. +func (h *AuthHandler) RegisterRoutes(r *gin.RouterGroup) { + r.POST("/auth/login", h.LoginCollector) + r.POST("/auth/logout", h.Logout) + r.GET("/auth/me", h.Me) +} + +// LoginCollector authenticates data collector and returns JWT access token. +func (h *AuthHandler) LoginCollector(c *gin.Context) { + var req CollectorLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + req.OperatorID = strings.TrimSpace(req.OperatorID) + if req.OperatorID == "" || req.Password == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "operator_id and password are required"}) + return + } + + var row collectorAuthRow + err := h.db.Get(&row, ` + SELECT id, name, operator_id, password_hash + FROM data_collectors + WHERE operator_id = ? AND deleted_at IS NULL + LIMIT 1 + `, req.OperatorID) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + if !row.PasswordHash.Valid || strings.TrimSpace(row.PasswordHash.String) == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(row.PasswordHash.String), []byte(req.Password)); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + + _, _ = h.db.Exec("UPDATE data_collectors SET last_login_at = NOW() WHERE id = ?", row.ID) + + claims := auth.NewCollectorClaims(row.ID, row.OperatorID) + token, err := auth.GenerateToken(claims, h.cfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) + return + } + + var resp CollectorLoginResponse + resp.AccessToken = token + resp.TokenType = "Bearer" + resp.ExpiresIn = h.cfg.JWTExpiryHours * 3600 + resp.Collector.ID = strconv.FormatInt(row.ID, 10) + resp.Collector.OperatorID = row.OperatorID + resp.Collector.Name = row.Name + + c.JSON(http.StatusOK, resp) +} + +// Logout ends the current session. In this MVP it is a no-op on the server side. +func (h *AuthHandler) Logout(c *gin.Context) { + // MVP logout: client drops token. Keep endpoint for symmetry. + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// Me returns the current authenticated collector identity. +func (h *AuthHandler) Me(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if strings.TrimSpace(authHeader) == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header format"}) + return + } + + claims, err := auth.ParseToken(parts[1], h.cfg) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + return + } + + var row struct { + ID int64 `db:"id"` + Name string `db:"name"` + OperatorID string `db:"operator_id"` + } + if err := h.db.Get(&row, ` + SELECT id, name, operator_id + FROM data_collectors + WHERE id = ? AND deleted_at IS NULL + LIMIT 1 + `, claims.CollectorID); err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusUnauthorized, gin.H{"error": "collector not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "database error"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "collector_id": claims.CollectorID, + "operator_id": row.OperatorID, + "name": row.Name, + "role": claims.Role, + }) +} diff --git a/internal/api/handlers/batch.go b/internal/api/handlers/batch.go index 958eb95..3eb4c92 100644 --- a/internal/api/handlers/batch.go +++ b/internal/api/handlers/batch.go @@ -54,6 +54,7 @@ type BatchListItem struct { Notes string `json:"notes,omitempty" db:"notes"` Status string `json:"status" db:"status"` EpisodeCount int `json:"episode_count" db:"episode_count"` + TaskCount int `json:"task_count" db:"task_count"` StartedAt string `json:"started_at,omitempty"` EndedAt string `json:"ended_at,omitempty"` Metadata any `json:"metadata,omitempty"` @@ -78,6 +79,7 @@ type batchRow struct { Notes sql.NullString `db:"notes"` Status string `db:"status"` EpisodeCount int `db:"episode_count"` + TaskCount int `db:"task_count"` StartedAt sql.NullTime `db:"started_at"` EndedAt sql.NullTime `db:"ended_at"` Metadata sql.NullString `db:"metadata"` @@ -134,6 +136,7 @@ func batchListItemFromRow(r batchRow) BatchListItem { Notes: notes, Status: r.Status, EpisodeCount: r.EpisodeCount, + TaskCount: r.TaskCount, StartedAt: startedAt, EndedAt: endedAt, Metadata: parseNullableJSON(r.Metadata), @@ -219,22 +222,29 @@ func (h *BatchHandler) ListBatches(c *gin.Context) { query := fmt.Sprintf(` SELECT - id, - batch_id, - order_id, - workstation_id, - name, - notes, - status, - episode_count, - started_at, - ended_at, - CAST(metadata AS CHAR) AS metadata, - created_at, - updated_at - FROM batches + b.id, + b.batch_id, + b.order_id, + b.workstation_id, + b.name, + b.notes, + b.status, + b.episode_count, + COALESCE(tc.task_count, 0) AS task_count, + b.started_at, + b.ended_at, + CAST(b.metadata AS CHAR) AS metadata, + b.created_at, + b.updated_at + FROM batches b + LEFT JOIN ( + SELECT batch_id, COUNT(*) AS task_count + FROM tasks + WHERE deleted_at IS NULL + GROUP BY batch_id + ) tc ON tc.batch_id = b.id WHERE %s - ORDER BY id DESC + ORDER BY b.id DESC LIMIT ? OFFSET ? `, whereClause) @@ -282,21 +292,28 @@ func (h *BatchHandler) GetBatch(c *gin.Context) { query := ` SELECT - id, - batch_id, - order_id, - workstation_id, - name, - notes, - status, - episode_count, - started_at, - ended_at, - CAST(metadata AS CHAR) AS metadata, - created_at, - updated_at - FROM batches - WHERE id = ? AND deleted_at IS NULL + b.id, + b.batch_id, + b.order_id, + b.workstation_id, + b.name, + b.notes, + b.status, + b.episode_count, + COALESCE(tc.task_count, 0) AS task_count, + b.started_at, + b.ended_at, + CAST(b.metadata AS CHAR) AS metadata, + b.created_at, + b.updated_at + FROM batches b + LEFT JOIN ( + SELECT batch_id, COUNT(*) AS task_count + FROM tasks + WHERE deleted_at IS NULL + GROUP BY batch_id + ) tc ON tc.batch_id = b.id + WHERE b.id = ? AND b.deleted_at IS NULL ` var r batchRow diff --git a/internal/api/handlers/data_collector.go b/internal/api/handlers/data_collector.go index a1a1e2e..fb3b4f9 100644 --- a/internal/api/handlers/data_collector.go +++ b/internal/api/handlers/data_collector.go @@ -17,6 +17,7 @@ import ( "github.com/gin-gonic/gin" "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" "archebase.com/keystone-edge/internal/logger" ) @@ -55,6 +56,7 @@ type CreateDataCollectorRequest struct { OperatorID string `json:"operator_id"` Email string `json:"email,omitempty"` Certification string `json:"certification,omitempty"` + Password string `json:"password,omitempty"` // #nosec G117 -- request DTO may include password for initial set Metadata interface{} `json:"metadata,omitempty"` } @@ -209,6 +211,7 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { req.OperatorID = strings.TrimSpace(req.OperatorID) req.Email = strings.TrimSpace(req.Email) req.Certification = strings.TrimSpace(req.Certification) + req.Password = strings.TrimSpace(req.Password) if req.Name == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) @@ -257,20 +260,35 @@ func (h *DataCollectorHandler) CreateDataCollector(c *gin.Context) { metadataStr = sql.NullString{String: string(metadataJSON), Valid: true} } + var passwordHash sql.NullString + password := req.Password + if password == "" { + password = "123456" + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + logger.Printf("[DC] Failed to hash password: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create data collector"}) + return + } + passwordHash = sql.NullString{String: string(hash), Valid: true} + result, err := h.db.Exec( `INSERT INTO data_collectors ( name, operator_id, email, + password_hash, certification, status, metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, req.Name, req.OperatorID, emailStr, + passwordHash, certStr, "active", metadataStr, @@ -373,6 +391,7 @@ type UpdateDataCollectorRequest struct { Email *string `json:"email,omitempty"` Certification *string `json:"certification,omitempty"` Status *string `json:"status,omitempty"` + Password *string `json:"password,omitempty"` // #nosec G117 -- request DTO may include password update Metadata json.RawMessage `json:"metadata,omitempty" swaggertype:"object"` } @@ -465,6 +484,22 @@ func (h *DataCollectorHandler) UpdateDataCollector(c *gin.Context) { args = append(args, certStr) } + if req.Password != nil { + pw := strings.TrimSpace(*req.Password) + if pw == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "password cannot be empty"}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + if err != nil { + logger.Printf("[DC] Failed to hash password: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update data collector"}) + return + } + updates = append(updates, "password_hash = ?") + args = append(args, sql.NullString{String: string(hash), Valid: true}) + } + if req.Status != nil { status := strings.TrimSpace(*req.Status) if !validStatuses[status] { diff --git a/internal/auth/claims.go b/internal/auth/claims.go new file mode 100644 index 0000000..eafaa00 --- /dev/null +++ b/internal/auth/claims.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package auth provides JWT claim types and helpers for authentication. +package auth + +import "github.com/golang-jwt/jwt/v5" + +// Claims represents JWT claims for collector authentication. +type Claims struct { + CollectorID int64 `json:"collector_id"` + OperatorID string `json:"operator_id"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// NewCollectorClaims creates claims for a data collector identity. +func NewCollectorClaims(collectorID int64, operatorID string) *Claims { + return &Claims{ + CollectorID: collectorID, + OperatorID: operatorID, + Role: "data_collector", + } +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..3ee1b51 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +package auth + +import ( + "errors" + "time" + + "archebase.com/keystone-edge/internal/config" + "github.com/golang-jwt/jwt/v5" +) + +var ( + // ErrInvalidToken indicates the token is malformed, signed with an unexpected + // algorithm, or otherwise fails validation. + ErrInvalidToken = errors.New("invalid token") + // ErrExpiredToken indicates the token is expired. + ErrExpiredToken = errors.New("token has expired") +) + +// GenerateToken signs the given claims into a JWT string using the auth config. +func GenerateToken(claims *Claims, cfg *config.AuthConfig) (string, error) { + claims.Issuer = cfg.Issuer + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Duration(cfg.JWTExpiryHours) * time.Hour)) + claims.IssuedAt = jwt.NewNumericDate(time.Now()) + claims.NotBefore = jwt.NewNumericDate(time.Now()) + + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return tok.SignedString([]byte(cfg.JWTSecret)) +} + +// ParseToken parses and validates a JWT string and returns its Claims on success. +func ParseToken(tokenString string, cfg *config.AuthConfig) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, ErrInvalidToken + } + return []byte(cfg.JWTSecret), nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + return claims, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index d372d5c..ecdf956 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { Storage StorageConfig QA QAConfig Sync SyncConfig + Auth AuthConfig Features FeaturesConfig Monitoring MonitoringConfig Resources ResourceLimitsConfig @@ -113,6 +114,14 @@ type RecorderConfig struct { ResponseTimeout int // seconds } +// AuthConfig JWT authentication configuration (collector login). +type AuthConfig struct { + JWTSecret string // #nosec G117 -- signing secret loaded from env; must exist in config struct + Issuer string + JWTExpiryHours int + AllowNoAuthOnDev bool +} + // Load loads configuration from environment variables and defaults func Load() (*Config, error) { cfg := &Config{ @@ -158,6 +167,12 @@ func Load() (*Config, error) { MaxRetries: getEnvInt("KEYSTONE_SYNC_MAX_RETRIES", 5), CheckpointPath: getEnv("KEYSTONE_SYNC_CHECKPOINT_PATH", "/var/lib/keystone/.checkpoint"), }, + Auth: AuthConfig{ + JWTSecret: getEnv("KEYSTONE_JWT_SECRET", ""), + Issuer: getEnv("KEYSTONE_JWT_ISSUER", "keystone-edge"), + JWTExpiryHours: getEnvInt("KEYSTONE_JWT_EXPIRY_HOURS", 24), + AllowNoAuthOnDev: getEnvBool("KEYSTONE_AUTH_ALLOW_NO_AUTH_ON_DEV", false), + }, Features: FeaturesConfig{ StrataEnabled: false, SlateEnabled: false, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d6e8d34..b19b7fa 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -148,6 +148,9 @@ func TestConfigValidate(t *testing.T) { AccessKey: "key", SecretKey: "secret", }, + Auth: AuthConfig{ + JWTSecret: "test-secret", + }, }, wantErr: false, }, diff --git a/internal/middleware/jwt_auth.go b/internal/middleware/jwt_auth.go new file mode 100644 index 0000000..04f7e8c --- /dev/null +++ b/internal/middleware/jwt_auth.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 ArcheBase +// +// SPDX-License-Identifier: MulanPSL-2.0 + +// Package middleware provides HTTP middleware for request authentication. +package middleware + +import ( + "net/http" + "strings" + + "archebase.com/keystone-edge/internal/auth" + "archebase.com/keystone-edge/internal/config" + "github.com/gin-gonic/gin" +) + +// ClaimsKey is the gin.Context key used to store parsed JWT claims. +const ClaimsKey = "auth_claims" + +// JWTAuth validates JWT tokens. +// Header: Authorization: Bearer +func JWTAuth(cfg *config.AuthConfig) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header format"}) + return + } + + claims, err := auth.ParseToken(parts[1], cfg) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired token"}) + return + } + + c.Set(ClaimsKey, claims) + c.Next() + } +} + +// GetClaims returns JWT claims previously stored in the gin.Context by JWTAuth. +func GetClaims(c *gin.Context) *auth.Claims { + if v, ok := c.Get(ClaimsKey); ok { + return v.(*auth.Claims) + } + return nil +} diff --git a/internal/server/server.go b/internal/server/server.go index df3c3dc..7ac9dc6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -32,6 +32,7 @@ import ( type Server struct { cfg *config.Config health *handlers.HealthHandler + auth *handlers.AuthHandler transfer *handlers.TransferHandler recorder *handlers.RecorderHandler episode *handlers.EpisodeHandler @@ -68,6 +69,10 @@ func New(cfg *config.Config, db *sqlx.DB, s3Client *s3.Client) *Server { // Create handlers healthHandler := handlers.NewHealthHandler(nil, nil) + var authHandler *handlers.AuthHandler + if db != nil { + authHandler = handlers.NewAuthHandler(db, &cfg.Auth) + } // Create TransferHub and TransferHandler for Transfer Service transferHub := services.NewTransferHub(cfg.AxonTransfer.MaxEvents) @@ -118,6 +123,7 @@ func New(cfg *config.Config, db *sqlx.DB, s3Client *s3.Client) *Server { s := &Server{ cfg: cfg, health: healthHandler, + auth: authHandler, transfer: transferHandler, recorder: recorderHandler, episode: episodeHandler, @@ -182,16 +188,21 @@ func (s *Server) buildRoutes() http.Handler { // Health check - register only in API v1 group s.health.RegisterAPI(v1) + v1Routes := v1.Group("") + if s.auth != nil { + s.auth.RegisterRoutes(v1Routes) + } + // Transfer Service API - v1Transfer := v1.Group("/transfer") + v1Transfer := v1Routes.Group("/transfer") s.transfer.RegisterRoutes(v1Transfer) // Episodes API - v1Episodes := v1.Group("/episodes") + v1Episodes := v1Routes.Group("/episodes") s.episode.RegisterRoutes(v1Episodes) // Tasks API - v1Tasks := v1.Group("") + v1Tasks := v1Routes.Group("") s.task.RegisterRoutes(v1Tasks) if s.batch != nil { s.batch.RegisterRoutes(v1Tasks) @@ -234,12 +245,12 @@ func (s *Server) buildRoutes() http.Handler { } // Axon callbacks - v1Callbacks := v1.Group("/callbacks") + v1Callbacks := v1Routes.Group("/callbacks") // Task callbacks s.task.RegisterCallbackRoutes(v1Callbacks) - v1Recorder := v1.Group("/recorder") + v1Recorder := v1Routes.Group("/recorder") s.recorder.RegisterRoutes(v1Recorder) // Swagger documentation - serve at both root and api/v1 path diff --git a/internal/storage/database/migrations/000001_initial_schema.up.sql b/internal/storage/database/migrations/000001_initial_schema.up.sql index 8366072..94558da 100644 --- a/internal/storage/database/migrations/000001_initial_schema.up.sql +++ b/internal/storage/database/migrations/000001_initial_schema.up.sql @@ -158,6 +158,8 @@ CREATE TABLE IF NOT EXISTS data_collectors ( name VARCHAR(255) NOT NULL, operator_id VARCHAR(100) NOT NULL, email VARCHAR(255), + password_hash VARCHAR(255) NULL COMMENT 'Bcrypt hash for password login', + last_login_at TIMESTAMP NULL COMMENT 'Last successful login time', certification VARCHAR(100), status ENUM('active', 'inactive', 'on_leave') DEFAULT 'active', metadata JSON DEFAULT NULL,