// SFTP credential store. Pattern ported from internal/userstore.go // (bcrypt-hashed secret, timing-safe "not found" handling) but deliberately // kept separate from the `users` table: an SFTP credential is a narrow, // independently revocable secret scoped to exactly one tenant's inbox // folder, not a full login account. See internal/sftpserver for the server // that authenticates against this store and internal/api/sftp_handlers.go // for the admin CRUD API. package storage import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "golang.org/x/crypto/bcrypt" ) const sftpBcryptCost = 12 // sftpDummyBcryptHash burns bcrypt time on "username not found" so that // VerifySFTPLogin does not leak credential existence via timing, mirroring // userstore.dummyBcryptHash. const sftpDummyBcryptHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEeO4TW/OZ/6PdTdSU0/eV1JCJXo.0DGvTa" // ErrSFTPCredentialNotFound is returned when a credential lookup/revoke // targets a username or ID that doesn't exist (or doesn't belong to the // given tenant). var ErrSFTPCredentialNotFound = errors.New("storage: sftp credential not found") // SFTPCredential is a per-tenant SFTP login. The password is never stored or // returned in plaintext after creation. type SFTPCredential struct { ID int64 `json:"id"` TenantID int64 `json:"tenant_id"` Username string `json:"username"` Active bool `json:"active"` CreatedAt time.Time `json:"created_at"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` } func (s *Store) initSFTPCredentialsSchema(ctx context.Context) error { _, err := s.db.Exec(ctx, ` CREATE TABLE IF NOT EXISTS sftp_credentials ( id BIGSERIAL PRIMARY KEY, tenant_id BIGINT NOT NULL, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_login_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_sftp_credentials_tenant ON sftp_credentials(tenant_id); `) if err != nil { return fmt.Errorf("storage: create sftp_credentials table: %w", err) } return nil } // CreateSFTPCredential creates a new SFTP credential for a tenant. The // caller-supplied plaintext password is bcrypt-hashed before storage and is // never persisted or retrievable again — the caller (the admin API handler) // must return it to the requester exactly once, in the create response. func (s *Store) CreateSFTPCredential(ctx context.Context, tenantID int64, username, password string) (*SFTPCredential, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), sftpBcryptCost) if err != nil { return nil, fmt.Errorf("storage: sftp bcrypt: %w", err) } var c SFTPCredential err = s.db.QueryRow(ctx, ` INSERT INTO sftp_credentials (tenant_id, username, password_hash, active, created_at) VALUES ($1, $2, $3, true, now()) RETURNING id, tenant_id, username, active, created_at, last_login_at `, tenantID, username, string(hash), ).Scan(&c.ID, &c.TenantID, &c.Username, &c.Active, &c.CreatedAt, &c.LastLoginAt) if err != nil { return nil, fmt.Errorf("storage: create sftp credential: %w", err) } return &c, nil } // VerifySFTPLogin checks a username/password pair against sftp_credentials // and returns the credential (with tenant scope) on success. Inactive // credentials and wrong passwords both fail; unknown usernames burn a dummy // bcrypt compare to avoid a timing side-channel that would reveal which // usernames exist. func (s *Store) VerifySFTPLogin(ctx context.Context, username, password string) (*SFTPCredential, error) { var c SFTPCredential var hash string err := s.db.QueryRow(ctx, ` SELECT id, tenant_id, username, password_hash, active, created_at, last_login_at FROM sftp_credentials WHERE username = $1 `, username).Scan(&c.ID, &c.TenantID, &c.Username, &hash, &c.Active, &c.CreatedAt, &c.LastLoginAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { _ = bcrypt.CompareHashAndPassword([]byte(sftpDummyBcryptHash), []byte(password)) return nil, fmt.Errorf("storage: sftp login: credential not found") } return nil, fmt.Errorf("storage: sftp login: %w", err) } if !c.Active { return nil, fmt.Errorf("storage: sftp login: credential revoked") } if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { return nil, fmt.Errorf("storage: sftp login: wrong password") } return &c, nil } // TouchSFTPLastLogin sets last_login_at = now() for a credential. Called // after a successful SFTP authentication. func (s *Store) TouchSFTPLastLogin(ctx context.Context, id int64) error { _, err := s.db.Exec(ctx, `UPDATE sftp_credentials SET last_login_at = now() WHERE id = $1`, id) return err } // ListSFTPCredentials returns all SFTP credentials belonging to a tenant, // newest first. Password hashes are never included in the returned struct. func (s *Store) ListSFTPCredentials(ctx context.Context, tenantID int64) ([]SFTPCredential, error) { rows, err := s.db.Query(ctx, ` SELECT id, tenant_id, username, active, created_at, last_login_at FROM sftp_credentials WHERE tenant_id = $1 ORDER BY created_at DESC `, tenantID) if err != nil { return nil, fmt.Errorf("storage: list sftp credentials: %w", err) } defer rows.Close() out := make([]SFTPCredential, 0) for rows.Next() { var c SFTPCredential if err := rows.Scan(&c.ID, &c.TenantID, &c.Username, &c.Active, &c.CreatedAt, &c.LastLoginAt); err != nil { return nil, fmt.Errorf("storage: scan sftp credential: %w", err) } out = append(out, c) } return out, rows.Err() } // RevokeSFTPCredential deactivates (soft-deletes) a credential, scoped to the // owning tenant. Using active=false rather than a hard delete keeps the // row (and its audit trail via created_at/last_login_at) around for GoBD // traceability. func (s *Store) RevokeSFTPCredential(ctx context.Context, id, tenantID int64) error { tag, err := s.db.Exec(ctx, `UPDATE sftp_credentials SET active = false WHERE id = $1 AND tenant_id = $2`, id, tenantID) if err != nil { return fmt.Errorf("storage: revoke sftp credential: %w", err) } if tag.RowsAffected() == 0 { return ErrSFTPCredentialNotFound } return nil }