Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion cmd/workgit/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ var userRegenerateTokenCmd = &cobra.Command{
RunE: runUserRegenerateToken,
}

// creation flags with the root command.
func init() {
userCreateCmd.Flags().String("email", "", "user email")
userCreateCmd.Flags().String("name", "", "full name")
Expand All @@ -102,6 +103,13 @@ func init() {
rootCmd.AddCommand(userCmd)
}

// runUserCreate creates a new user for the provided username, prompting for and confirming a password,
// and prints the created user's API token.
//
// It loads the CLI configuration, persists the user in the configured storage, and, if the
// --admin flag is set, grants global admin privileges. Output is emitted as JSON when the
// global JSON output mode is enabled, otherwise as human-readable text. Returns an error on
// configuration, validation (including password mismatch), or storage failures.
func runUserCreate(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand Down Expand Up @@ -157,6 +165,10 @@ func runUserCreate(cmd *cobra.Command, args []string) error {
return nil
}

// runUserList lists users from the configured user store and prints them either as JSON or as a tabulated table.
// The JSON output contains objects with `username`, `email`, `active`, and `admin` fields; the table shows columns
// USERNAME, EMAIL, ACTIVE, ADMIN, and REPOS. It loads configuration and initializes the user store, and returns an
// error if those setup steps fail.
func runUserList(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand Down Expand Up @@ -204,6 +216,10 @@ func runUserList(cmd *cobra.Command, args []string) error {
return nil
}

// runUserDelete deletes the named user from storage and prints a confirmation message
// or a JSON result. It prompts for interactive confirmation when JSON output is disabled.
// Returns an error if loading configuration, initializing the user store, or deleting the
// user fails.
func runUserDelete(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand Down Expand Up @@ -241,6 +257,12 @@ func runUserDelete(cmd *cobra.Command, args []string) error {
return nil
}

// runUserSetPermission sets a user's permission for a repository and persists the change.
//
// It validates the provided permission string, normalizes the repository name, and updates
// the user's permission in the configured user store. On success it prints either JSON
// or a human-readable confirmation. It returns an error if configuration loading,
// permission validation, user store initialization, or the permission update fail.
func runUserSetPermission(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand Down Expand Up @@ -278,6 +300,13 @@ func runUserSetPermission(cmd *cobra.Command, args []string) error {
return nil
}

// runUserShow prints detailed information for the specified user to stdout.
// When jsonOutput is true it emits a JSON object containing username, email,
// full_name, active, admin, and created_at (the API token is omitted); otherwise
// it prints human-readable fields and, if present, a tabulated list of repository
// permissions.
// It returns an error if configuration loading, user store initialization, or
// user retrieval fails.
func runUserShow(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand Down Expand Up @@ -325,6 +354,13 @@ func runUserShow(cmd *cobra.Command, args []string) error {
return nil
}

// runUserSetAdmin sets or clears the global admin flag for the specified user.
//
// The function expects args[0] to be the username and args[1] to be "true" or "false"
// to indicate whether global admin privileges should be granted. It updates the user
// store accordingly and prints either a JSON result or a human-readable confirmation.
// Returns an error if configuration loading, user store initialization, or the
// update operation fails.
func runUserSetAdmin(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand Down Expand Up @@ -357,6 +393,11 @@ func runUserSetAdmin(cmd *cobra.Command, args []string) error {
return nil
}

// runUserResetPassword resets the password for the specified user by prompting for a new
// password and confirmation, then updating the stored user record.
//
// It returns an error if loading configuration fails, the prompted passwords do not match,
// the user store cannot be initialized, or updating the user's password fails.
func runUserResetPassword(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand Down Expand Up @@ -398,6 +439,8 @@ func runUserResetPassword(cmd *cobra.Command, args []string) error {
return nil
}

// runUserRegenerateToken regenerates a user's API token.
// It returns an error if loading configuration, initializing the user store, or regenerating the token fails.
func runUserRegenerateToken(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
Expand Down Expand Up @@ -427,6 +470,8 @@ func runUserRegenerateToken(cmd *cobra.Command, args []string) error {
return nil
}

// promptPassword prints the provided prompt and reads a password from the terminal without echo.
// It returns the entered password as a string and an error if the read fails.
func promptPassword(prompt string) (string, error) {
fmt.Print(prompt)
password, err := term.ReadPassword(int(syscall.Stdin))
Expand All @@ -437,7 +482,9 @@ func promptPassword(prompt string) (string, error) {
return string(password), nil
}

// isValidPermString reports whether s is one of the supported permission values:
// "none", "read", "write", or "admin" (case-insensitive).
func isValidPermString(s string) bool {
s = strings.ToLower(s)
return s == "none" || s == "read" || s == "write" || s == "admin"
}
}
20 changes: 15 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ type Authenticator struct {
// NewAuthenticator creates a new authenticator
// storagePath should be a directory where the users data will be stored.
// If storagePath is a root-like path, it will be joined with "users" to ensure
// the users.json file is created inside a users directory.
// NewAuthenticator creates and returns an Authenticator configured with cfg.
// If storagePath is non-empty, a UserStore is created at storagePath/users (the function avoids double-appending "users")
// and attached to the authenticator. The returned Authenticator is populated with a token lookup built from cfg.Tokens.
// If cfg.MTLS.CAFile is set, the file is loaded and used to populate the CA pool for client certificate verification.
// Returns an error if user store initialization or CA loading/parsing fails.
func NewAuthenticator(cfg config.AuthConfig, storagePath string) (*Authenticator, error) {
a := &Authenticator{
cfg: cfg,
Expand Down Expand Up @@ -181,6 +185,8 @@ func (a *Authenticator) authenticate(r *http.Request) (*User, error) {
return nil, ErrUnauthorized
}

// shortTokenPrefix returns the first eight characters of tok if tok is longer than
// eight characters; otherwise it returns tok unchanged.
func shortTokenPrefix(tok string) string {
if len(tok) <= 8 {
return tok
Expand Down Expand Up @@ -253,13 +259,14 @@ func (a *Authenticator) validateClientCert(cert *x509.Certificate) bool {
return true
}

// UserFromContext extracts user from context
// UserFromContext returns the User stored in ctx, if present, and a boolean indicating whether a User was found.
func UserFromContext(ctx context.Context) (*User, bool) {
user, ok := ctx.Value(userKey).(*User)
return user, ok
}

// NormalizeRepo ensures repository names have a consistent format by appending ".git" if missing
// NormalizeRepo ensures the repository name ends with ".git".
// If the input already has the ".git" suffix, it is returned unchanged.
func NormalizeRepo(repo string) string {
if !strings.HasSuffix(repo, ".git") {
return repo + ".git"
Expand All @@ -277,7 +284,10 @@ func (a *Authenticator) CheckPermission(username, repo string, required Permissi
return a.userStore.CheckAccess(username, normalizedRepo, required)
}

// RequireAuth returns true if authentication is required for the operation
// RequireAuth reports whether authentication is required for the given git service operation.
// Push operations ("git-receive-pack") always require authentication. Read operations
// ("git-upload-pack") do not require authentication when anonymousRead is true; otherwise
// authentication is required.
func RequireAuth(method, service string, anonymousRead bool) bool {
// Push always requires auth
if service == "git-receive-pack" {
Expand Down Expand Up @@ -345,4 +355,4 @@ func (a *Authenticator) TLSConfig(baseCfg *tls.Config) *tls.Config {
// GetUserStore returns the user store (for CLI commands)
func (a *Authenticator) GetUserStore() *UserStore {
return a.userStore
}
}
104 changes: 14 additions & 90 deletions internal/auth/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ type UserStore struct {
tokens map[string]string // token -> username
mu sync.RWMutex
filePath string
lastMod int64 // Last modification time to track when to reload
}

// NewUserStore creates a new user store
// NewUserStore creates a UserStore that persists user data to a users.json file under the given storagePath.
// If storagePath does not have a base of "users", the file will be placed in a "users" subdirectory.
// The returned store is initialized with empty in-memory maps and attempts to load existing data;
// an error is returned only if loading fails for reasons other than the storage file not existing.
func NewUserStore(storagePath string) (*UserStore, error) {
dir := storagePath
if filepath.Base(storagePath) != "users" {
Expand All @@ -66,17 +68,10 @@ func NewUserStore(storagePath string) (*UserStore, error) {
users: make(map[string]*UserInfo),
tokens: make(map[string]string),
filePath: filepath.Join(dir, "users.json"),
lastMod: 0, // Initialize to 0 so that first check will trigger reload if needed
}
if err := store.load(); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("load users: %w", err)
}

// Update the last modification time to current file time if it exists
if info, err := os.Stat(store.filePath); err == nil {
store.lastMod = info.ModTime().Unix()
}

return store, nil
}

Expand Down Expand Up @@ -138,11 +133,6 @@ func (s *UserStore) CreateUser(username, password, email, fullName string) (*Use

// GetUser retrieves a user by username
func (s *UserStore) GetUser(username string) (*UserInfo, error) {
// Check if file has been modified and reload if necessary
if s.shouldReload() {
s.reload()
}

s.mu.RLock()
defer s.mu.RUnlock()

Expand All @@ -156,11 +146,6 @@ func (s *UserStore) GetUser(username string) (*UserInfo, error) {

// GetUserByToken retrieves a user by token
func (s *UserStore) GetUserByToken(token string) (*UserInfo, error) {
// Check if file has been modified and reload if necessary
if s.shouldReload() {
s.reload()
}

s.mu.RLock()
defer s.mu.RUnlock()

Expand Down Expand Up @@ -305,11 +290,6 @@ func (s *UserStore) SetPermission(username, repo string, perm Permission) error

// GetPermission gets a user's permission for a repository
func (s *UserStore) GetPermission(username, repo string) Permission {
// Check if file has been modified and reload if necessary
if s.shouldReload() {
s.reload()
}

s.mu.RLock()
defer s.mu.RUnlock()

Expand All @@ -335,70 +315,6 @@ func (s *UserStore) GetPermission(username, repo string) Permission {
return PermissionNone
}

// shouldReload checks if the user file has been modified since last load
func (s *UserStore) shouldReload() bool {
info, err := os.Stat(s.filePath)
if err != nil {
return false // Can't stat the file, so don't reload
}

return info.ModTime().Unix() > s.lastMod
}

// reload reloads user data from the file
func (s *UserStore) reload() {
s.mu.Lock()
defer s.mu.Unlock()

info, err := os.Stat(s.filePath)
if err != nil {
return
}

// Only reload if file is actually newer
if info.ModTime().Unix() <= s.lastMod {
return
}

// Clear current data
s.users = make(map[string]*UserInfo)
s.tokens = make(map[string]string)

if err := s.load(); err != nil {
// If reload fails, the data remains empty, which is not ideal
// But we should at least preserve the old data if the file is corrupted
// This is an issue - let's try to keep the old data if load fails
// Actually, let's not do it this way - let's load into temporary data first
tempUsers := make(map[string]*UserInfo)
tempTokens := make(map[string]string)

// Try to load users into temporary storage
data, err := os.ReadFile(s.filePath)
if err != nil {
// Restore old data if read fails
return
}

var users []*UserInfo
if err := json.Unmarshal(data, &users); err != nil {
// If unmarshal fails, restore old data
return
}

for _, user := range users {
tempUsers[user.Username] = user
tempTokens[user.Token] = user.Username
}

// Update the stored data
s.users = tempUsers
s.tokens = tempTokens
}

// Update the last modification time to current file time
s.lastMod = info.ModTime().Unix()
}

// SetGlobalAdmin sets or removes global admin status
func (s *UserStore) SetGlobalAdmin(username string, isAdmin bool) error {
s.mu.Lock()
Expand Down Expand Up @@ -524,11 +440,13 @@ func (s *UserStore) save() error {

// Helper functions

// normalizeRepo ensures repository names have a consistent format by appending ".git" if missing
// normalizeRepo returns the repository name in a canonical form, appending ".git" if it is missing.
func normalizeRepo(repo string) string {
return NormalizeRepo(repo)
}

// hashPassword returns a bcrypt hash of the provided password using the default cost.
// It returns the hashed password as a string, or an error if hashing fails.
func hashPassword(password string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
Expand All @@ -537,10 +455,13 @@ func hashPassword(password string) (string, error) {
return string(h), nil
}

// verifyPassword reports whether the provided password matches the given bcrypt hash; it returns true if they match, false otherwise.
func verifyPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

// generateToken generates a URL-safe base64-encoded 256-bit random token.
// It returns an error if the system random source fails.
func generateToken() (string, error) {
b := make([]byte, 32) // 256-bit
if _, err := rand.Read(b); err != nil {
Expand All @@ -549,6 +470,8 @@ func generateToken() (string, error) {
return base64.RawURLEncoding.EncodeToString(b), nil
}

// isValidPermission reports whether perm is a recognized Permission value.
// Recognized values are PermissionNone, PermissionRead, PermissionWrite, and PermissionAdmin.
func isValidPermission(perm Permission) bool {
switch perm {
case PermissionNone, PermissionRead, PermissionWrite, PermissionAdmin:
Expand All @@ -558,6 +481,7 @@ func isValidPermission(perm Permission) bool {
}
}

// hasPermission reports whether the given user permission meets or exceeds the required permission level.
func hasPermission(user, required Permission) bool {
levels := map[Permission]int{
PermissionNone: 0,
Expand All @@ -567,4 +491,4 @@ func hasPermission(user, required Permission) bool {
}

return levels[user] >= levels[required]
}
}
Loading