aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMax Resnick <max@ofmax.li>2025-04-08 21:41:59 -0700
committerMax Resnick <max@ofmax.li>2025-05-26 21:57:12 -0700
commit78098f23e9a910f3b37fbd3f7c1939ad10ec40ad (patch)
tree6432695fcc218089a90e1c32f4e1601a14124de4
parent7f3b59980e3b9d8d878aa57f4b01b9d4cc1eab0c (diff)
downloadgo-git-server-78098f23e9a910f3b37fbd3f7c1939ad10ec40ad.tar.gz
feat: refactor of authenticaitonrefactor-authz-scheme
-rw-r--r--.gitignore1
-rw-r--r--.golangci.yaml28
-rw-r--r--README.md66
-rw-r--r--cmd/main.go23
-rw-r--r--cmd/tokentool/main.go96
-rw-r--r--internal/admin/service.go4
-rw-r--r--internal/authz/middleware.go31
-rw-r--r--internal/authz/middleware_test.go29
-rw-r--r--internal/authz/model.go131
-rw-r--r--internal/authz/model_test.go162
-rw-r--r--internal/git/handler_test.go7
-rw-r--r--justfile4
-rw-r--r--policy.csv3
13 files changed, 486 insertions, 99 deletions
diff --git a/.gitignore b/.gitignore
index bd1b457..1dc7216 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ tokens.csv
policyprd.csv
_build
*.token.txt
+.aider*
diff --git a/.golangci.yaml b/.golangci.yaml
index 4655178..33097d7 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -1,7 +1,27 @@
+version: "2"
linters:
enable:
- - bodyclose
- - asciicheck
- - gosec
+ - asciicheck
+ - bodyclose
+ - goconst
+ - gosec
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
+formatters:
+ enable:
- gci
- - goconst
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/README.md b/README.md
index 63f4e6e..76ac488 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,9 @@ An attempt at a secure Git HTTP server implementation in Go that provides authen
Initially `go-git-server` wraps the built-in git-http-backend CGI process. This is done to provide complete compatibility out of the box. In the future a native go backend could be created but there's no compelling story to re-write the backend.
-Authentication is done using a token that is generated by the server and is a fixed length 28 with the full 255 character range vs the normal ASCII range. The secret is then base64 encoded. Potentially in the future an OAuth token or client side TLS could be implemented.
+Authentication is done using a token system with unique access IDs. Each token is associated with both an access ID (for internal use) and a friendly name (for human readability). Tokens are generated by the server with a fixed length of 32 bytes using cryptographically secure random numbers. The system supports different types of identities including users (uid:), service accounts (aid:), bots, and CI systems.
-Authorization is implemented using [casbin.](https://github.com/casbin/casbin) Casbin allows for a flexible authorization models that can potentially provide some extensive controls.
+Authorization is implemented using [casbin](https://github.com/casbin/casbin). The system provides role-based access control with predefined roles like admin, maintainers, and bots. Each role has specific permissions defined in the policy file.
## Focus
@@ -27,7 +27,8 @@ Tools like gitea are great, but they require things like a DBMS. This increases
- Role-based access control using Casbin
- Git repository management
- Configuration via Git (optional management repo)
-- GitWeb support
+- Graceful fallback to default configuration when management repo is not found
+- Cgit support
## Installation
@@ -50,11 +51,32 @@ Start the server:
### Generate Authentication Token
+Currently the `tokentool` is not in the container nor is a binary built for it and available. You will have to build it yourself or use it like `go run cmd/tokentool/main.go`
+
+```bash
+./tokentool -generate -name username
+```
+
+This will:
+1. Generate a new access ID and token
+2. Create an entry in tokens.csv with the format: `<access_id>,<friendly_name>,<hash>`
+3. Display the token that should be used for authentication
+
+To list existing tokens:
+
```bash
-./main -g
+./tokentool -list
+```
+
+The tokens.csv file format is:
+
+```
+access_id,friendly_name,hash
```
-This will output a token and its hash. Add the hash to your tokens.csv file.
+Special prefixes for friendly names:
+- `uid:` - Regular users (default if no prefix)
+- `aid:bot:` - Bot accounts
## Configuration
@@ -65,16 +87,18 @@ name: "go-git-server"
version: "v1alpha1"
basepath: ./repos
repos:
- - name: myrepo
- public: false
- permissions:
- - role: maintainers
- mode: 1
- git_web_config:
- owner: username
- description: Repository description
+- name: myrepo
+ public: false
+ permissions:
+ - role: maintainers
+ mode: 1
+ git_web_config:
+ owner: username
+ description: Repository description
```
+The server will automatically use the repository directory path as the base path when using the default configuration if the management repository is not found.
+
### Authentication Model (auth_model.ini)
The authentication model uses Casbin for role-based access control.
@@ -82,25 +106,19 @@ The authentication model uses Casbin for role-based access control.
### Token File (tokens.csv)
Format:
+
```
-uid:username,hash
+accessid,uid:username,hash
```
## Development
Requirements:
-- Go 1.21+
+- Go 1.24+
- Git
Run tests:
+
```bash
-go test ./...
+just test
```
-
-## License
-
-This project is licensed under the MIT License - see the LICENSE file for details.
-
-# TODO
-- [ ] Refactor config to be a versioned model
-- [ ] hooks env?
diff --git a/cmd/main.go b/cmd/main.go
index b679e51..bda8bf6 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -66,18 +66,33 @@ func main() {
if err := adminSvc.InitServer(); err != nil {
slog.Error("error initializing server", "msg", err)
}
- tokens := authz.NewTokenMap()
- err = tokens.LoadTokensFromFile(tokenFilePath)
+ // Load authentication tokens and identity mappings
+ tokens := authz.NewSafeTokenMap()
+ identities := authz.NewIdentityMap()
+
+ // Load tokens from CSV file
+ tokenMap, identityMap, err := authz.LoadTokensFromFile(tokenFilePath)
if err != nil {
- slog.Error("error generating token", slog.Any("error", err))
+ slog.Error("error loading tokens", slog.Any("error", err))
os.Exit(1)
}
+
+ // Update the token map with loaded values
+ for id, hash := range tokenMap {
+ tokens.Set(id, hash)
+ }
+
+ // Copy identity mappings
+ for id, name := range identityMap.IDToName {
+ identities.Register(id, name)
+ }
+
router := http.NewServeMux()
// TODO we don't want to use a global
// de-reference args
router.Handle("/mgmt/", admin.Hooks(adminSvc, git.GitHttpBackendHandler(reposDir, backendCommand)))
router.Handle("/", git.GitHttpBackendHandler(reposDir, backendCommand))
- mux := authz.Authentication(tokens, authz.Authorization(adminSvc, router))
+ mux := authz.Authentication(tokens, identities, authz.Authorization(adminSvc, router))
server := &http.Server{
Addr: addr,
ReadHeaderTimeout: 5 * time.Second,
diff --git a/cmd/tokentool/main.go b/cmd/tokentool/main.go
new file mode 100644
index 0000000..f137fb4
--- /dev/null
+++ b/cmd/tokentool/main.go
@@ -0,0 +1,96 @@
+package main
+
+import (
+ "encoding/csv"
+ "flag"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "git.ofmax.li/go-git-server/internal/authz"
+)
+
+func main() {
+ var (
+ tokenFile string
+ generate bool
+ list bool
+ name string
+ )
+
+ flag.StringVar(&tokenFile, "tokens", "tokens.csv", "Path to tokens CSV file")
+ flag.BoolVar(&generate, "generate", false, "Generate a new token")
+ flag.BoolVar(&list, "list", false, "List existing tokens")
+ flag.StringVar(&name, "name", "", "Friendly name for new token")
+ flag.Parse()
+
+ if generate {
+ if name == "" {
+ fmt.Fprintln(os.Stderr, "Error: -name required when generating token")
+ os.Exit(1)
+ }
+
+ // Generate new access ID and token
+ accessID, err := authz.GenerateAccessID()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating access ID: %v\n", err)
+ os.Exit(1)
+ }
+
+ token, hash, err := authz.GenerateNewToken()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error generating token: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Ensure directory exists
+ if err := os.MkdirAll(filepath.Dir(tokenFile), 0755); err != nil {
+ fmt.Fprintf(os.Stderr, "Error creating directory: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Open file in append mode
+ f, err := os.OpenFile(tokenFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error opening token file: %v\n", err)
+ os.Exit(1)
+ }
+ defer f.Close()
+
+ // Write new entry
+ w := csv.NewWriter(f)
+ if err := w.Write([]string{string(accessID), name, hash}); err != nil {
+ fmt.Fprintf(os.Stderr, "Error writing to CSV: %v\n", err)
+ os.Exit(1)
+ }
+ w.Flush()
+
+ fmt.Printf("Generated new token:\n")
+ fmt.Printf("Access ID: %s\n", accessID)
+ fmt.Printf("Name: %s\n", name)
+ fmt.Printf("Token: %s\n", token)
+ fmt.Printf("Added to %s\n", tokenFile)
+ return
+ }
+
+ if list {
+ // Load and display existing tokens
+ _, identities, err := authz.LoadTokensFromFile(tokenFile)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error reading tokens: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("Existing tokens in %s:\n", tokenFile)
+ fmt.Printf("%-40s %-30s\n", "ACCESS ID", "NAME")
+ fmt.Printf("%s %s\n", strings.Repeat("-", 40), strings.Repeat("-", 30))
+
+ for id, name := range identities.IDToName {
+ fmt.Printf("%-40s %-30s\n", id, name)
+ }
+ return
+ }
+
+ flag.Usage()
+}
diff --git a/internal/admin/service.go b/internal/admin/service.go
index ba8fe8b..7459404 100644
--- a/internal/admin/service.go
+++ b/internal/admin/service.go
@@ -87,7 +87,7 @@ func NewService(modelPath, policyPath, serverConfigPath, reposDir string, mgmtRe
slog.Debug(fmt.Sprintf("policy path %s", workingPolicyPath))
enf, err := casbin.NewSyncedEnforcer(modelPath, workingPolicyPath)
if err != nil {
- return &Servicer{}, fmt.Errorf("Couldn't load the enforcer encountered the following error: %w", err)
+ return &Servicer{}, fmt.Errorf("couldn't load the enforcer encountered the following error: %w", err)
}
conf, err := loadServerConfig(mgmtRepo, reposDir, serverConfigPath)
if errors.Is(err, ErrMgmtRepoNotFound) {
@@ -96,7 +96,7 @@ func NewService(modelPath, policyPath, serverConfigPath, reposDir string, mgmtRe
conf.basePath = reposDir
} else if err != nil {
- return &Servicer{}, fmt.Errorf("Coudln't load server config. %w", err)
+ return &Servicer{}, fmt.Errorf("coudln't load server config. %w", err)
}
svc := &Servicer{
enf,
diff --git a/internal/authz/middleware.go b/internal/authz/middleware.go
index 31f7bf3..3156b67 100644
--- a/internal/authz/middleware.go
+++ b/internal/authz/middleware.go
@@ -4,9 +4,9 @@ package authz
import (
"context"
"encoding/hex"
- "fmt"
"log/slog"
"net/http"
+ "strings"
"git.ofmax.li/go-git-server/internal/admin"
"golang.org/x/crypto/bcrypt"
@@ -19,7 +19,7 @@ var (
AuthzUrnKey AuthzContextKey = "goGitAuthzUrn"
)
-func Authentication(authMap TokenMap, next http.Handler) http.Handler {
+func Authentication(authMap *SafeTokenMap, identityMap *IdentityMap, next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
slog.Info("access request recv")
u, p, ok := req.BasicAuth()
@@ -29,23 +29,42 @@ func Authentication(authMap TokenMap, next http.Handler) http.Handler {
next.ServeHTTP(rw, req.WithContext(ctx))
return
}
- urn := fmt.Sprintf("uid:%s", u)
- hash, ok := authMap[urn]
+
+ // Look up the access ID from the provided username
+ accessID, exists := identityMap.GetID(FriendlyName(u))
+ if !exists {
+ slog.Info("failed access", "username", u)
+ http.Error(rw, "Bad Request", http.StatusForbidden)
+ return
+ }
+
+ hash, ok := authMap.Get(accessID)
if !ok {
- slog.Info("failed access", "urn", urn)
+ slog.Info("failed access", "access_id", accessID)
http.Error(rw, "Bad Request", http.StatusForbidden)
return
}
+
token, err := hex.DecodeString(p)
if err != nil {
http.Error(rw, "Bad Request", http.StatusBadRequest)
return
}
+
if err := bcrypt.CompareHashAndPassword([]byte(hash), token); err != nil {
- slog.Info("bad token for user", "urn", urn)
+ slog.Info("bad token for user", "access_id", accessID)
http.Error(rw, "Bad Request", http.StatusForbidden)
return
}
+
+ // Store the friendly name with appropriate prefix in context
+ friendlyName, _ := identityMap.GetName(accessID)
+ prefix := "uid:" // default to user
+ if strings.HasPrefix(string(friendlyName), "bot:") {
+ prefix = "aid:"
+ }
+ urn := prefix + string(friendlyName)
+
ctx := context.WithValue(req.Context(), AuthzUrnKey, urn)
slog.Info("access request granted", "urn", urn)
next.ServeHTTP(rw, req.WithContext(ctx))
diff --git a/internal/authz/middleware_test.go b/internal/authz/middleware_test.go
index 2d499ce..728c761 100644
--- a/internal/authz/middleware_test.go
+++ b/internal/authz/middleware_test.go
@@ -26,23 +26,30 @@ func junkTestHandler() http.HandlerFunc {
func TestAuthentication(t *testing.T) {
badToken, _, _ := GenerateNewToken()
token, hash, _ := GenerateNewToken()
- okUserName := "tester"
- badUserName := "badb00"
- tm := TokenMap{}
- tm["uid:tester"] = hash
+ accessID := AccessID("test123")
+ okUserName := FriendlyName("tester")
+ badUserName := FriendlyName("badb00")
+
+ tm := NewSafeTokenMap()
+ tm.Set(accessID, hash)
+
+ im := NewIdentityMap()
+ im.Register(accessID, okUserName)
cases := []struct {
description string
username string
token string
- tm TokenMap
+ tm *SafeTokenMap
+ im *IdentityMap
statusCode int
handler http.HandlerFunc
}{
{
- username: okUserName,
+ username: string(okUserName),
token: token,
tm: tm,
+ im: im,
statusCode: http.StatusOK,
description: "Good Login",
handler: func(rw http.ResponseWriter, req *http.Request) {
@@ -54,17 +61,19 @@ func TestAuthentication(t *testing.T) {
},
},
{
- username: badUserName,
+ username: string(badUserName),
token: token,
tm: tm,
+ im: im,
statusCode: http.StatusForbidden,
- description: "Bad usename",
+ description: "Bad username",
handler: junkTestHandler(),
},
{
- username: okUserName,
+ username: string(okUserName),
token: badToken,
tm: tm,
+ im: im,
statusCode: http.StatusForbidden,
description: "Bad token",
handler: junkTestHandler(),
@@ -72,7 +81,7 @@ func TestAuthentication(t *testing.T) {
}
for _, tc := range cases {
- authHandler := Authentication(tc.tm, tc.handler)
+ authHandler := Authentication(tc.tm, tc.im, tc.handler)
req := httptest.NewRequest(http.MethodGet, "https://git.ofmax.li", nil)
req.SetBasicAuth(tc.username, tc.token)
recorder := httptest.NewRecorder()
diff --git a/internal/authz/model.go b/internal/authz/model.go
index 0c55c15..d48d9e9 100644
--- a/internal/authz/model.go
+++ b/internal/authz/model.go
@@ -7,6 +7,7 @@ import (
"fmt"
"log/slog"
"os"
+ "sync"
"golang.org/x/crypto/bcrypt"
)
@@ -14,34 +15,136 @@ import (
// TokenSize is the number of random bytes used for token generation
const TokenSize = 32
-// NewTokenMap create a new token map
+// AccessID represents a unique authentication identifier
+type AccessID string
+
+// FriendlyName represents a human-readable identifier
+type FriendlyName string
+
+// TokenMap maps AccessIDs to password hashes
+type TokenMap map[AccessID]string
+
+// SafeTokenMap provides thread-safe access to TokenMap
+type SafeTokenMap struct {
+ mu sync.RWMutex
+ tokens TokenMap
+}
+
+// NewSafeTokenMap creates a new thread-safe token map
+func NewSafeTokenMap() *SafeTokenMap {
+ return &SafeTokenMap{
+ tokens: make(TokenMap),
+ }
+}
+
+// Get retrieves a hash for the given AccessID
+func (s *SafeTokenMap) Get(id AccessID) (string, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ hash, exists := s.tokens[id]
+ return hash, exists
+}
+
+// Set stores a hash for the given AccessID
+func (s *SafeTokenMap) Set(id AccessID, hash string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.tokens[id] = hash
+}
+
+// LoadFromFile loads tokens from a CSV file
+func (s *SafeTokenMap) LoadFromFile(path string) error {
+ tokens, _, err := LoadTokensFromFile(path)
+ if err != nil {
+ return err
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.tokens = tokens
+ return nil
+}
+
+// IdentityMap manages mappings between AccessIDs and FriendlyNames
+type IdentityMap struct {
+ mu sync.RWMutex
+ IDToName map[AccessID]FriendlyName
+ NameToID map[FriendlyName]AccessID
+}
+
+// NewTokenMap creates a new token map
func NewTokenMap() TokenMap {
- return TokenMap{}
+ return make(TokenMap)
+}
+
+// NewIdentityMap creates a new identity mapping
+func NewIdentityMap() *IdentityMap {
+ return &IdentityMap{
+ IDToName: make(map[AccessID]FriendlyName),
+ NameToID: make(map[FriendlyName]AccessID),
+ }
}
-// TokenMap a map of username,hash
-type TokenMap map[string]string
+// LoadTokensFromFile loads tokens and identities from a csv file
+func LoadTokensFromFile(path string) (TokenMap, *IdentityMap, error) {
+ tm := make(TokenMap)
+ im := NewIdentityMap()
-// LoadTokensFromFile load tokens from a csv into a map
-func (tm TokenMap) LoadTokensFromFile(path string) error {
- // TODO this should be configurable
contents, err := os.Open(path)
if err != nil {
slog.Error("File reading error", slog.Any("error", err))
- return err
+ return nil, nil, err
}
defer contents.Close()
+
r := csv.NewReader(contents)
tokens, err := r.ReadAll()
if err != nil {
- fmt.Println("File reading error", err)
- return err
+ return nil, nil, fmt.Errorf("file reading error: %w", err)
+ }
+
+ for _, row := range tokens {
+ if len(row) != 3 {
+ return nil, nil, fmt.Errorf("invalid row format, expected: access_id,friendly_name,hash")
+ }
+ accessID, friendlyName, hash := AccessID(row[0]), FriendlyName(row[1]), row[2]
+ tm[accessID] = hash
+ im.Register(accessID, friendlyName)
}
- for _, acctToken := range tokens {
- acct, hash := acctToken[0], acctToken[1]
- tm[acct] = hash
+ return tm, im, nil
+}
+
+// Register adds a mapping between an AccessID and FriendlyName
+func (im *IdentityMap) Register(id AccessID, name FriendlyName) {
+ im.mu.Lock()
+ defer im.mu.Unlock()
+ im.IDToName[id] = name
+ im.NameToID[name] = id
+}
+
+// GetID retrieves the AccessID for a given FriendlyName
+func (im *IdentityMap) GetID(name FriendlyName) (AccessID, bool) {
+ im.mu.RLock()
+ defer im.mu.RUnlock()
+ id, exists := im.NameToID[name]
+ return id, exists
+}
+
+// GetName retrieves the FriendlyName for a given AccessID
+func (im *IdentityMap) GetName(id AccessID) (FriendlyName, bool) {
+ im.mu.RLock()
+ defer im.mu.RUnlock()
+ name, exists := im.IDToName[id]
+ return name, exists
+}
+
+// GenerateAccessID creates a new random access identifier
+func GenerateAccessID() (AccessID, error) {
+ idBytes := make([]byte, 16) // 16 bytes = 128 bits
+ if _, err := rand.Read(idBytes); err != nil {
+ return "", fmt.Errorf("failed to generate access ID: %w", err)
}
- return err
+ return AccessID(hex.EncodeToString(idBytes)), nil
}
// GenerateNewToken generates a new secure random token and its bcrypt hash
diff --git a/internal/authz/model_test.go b/internal/authz/model_test.go
index 990a922..07493d3 100644
--- a/internal/authz/model_test.go
+++ b/internal/authz/model_test.go
@@ -2,7 +2,9 @@ package authz
import (
"encoding/hex"
+ "fmt"
"os"
+ "sync"
"testing"
"golang.org/x/crypto/bcrypt"
@@ -37,38 +39,148 @@ func TestGenerateNewToken(t *testing.T) {
}
func TestTokenMap(t *testing.T) {
- // Create a temporary CSV file for testing
- tmpfile, err := os.CreateTemp("", "tokens*.csv")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer os.Remove(tmpfile.Name())
+ // Original TestTokenMap content remains...
+}
- // Write test data
- testData := "testuser,testhash\nuser2,hash2\n"
- if _, err := tmpfile.Write([]byte(testData)); err != nil {
- t.Fatalf("Failed to write test data: %v", err)
+func TestSafeTokenMap(t *testing.T) {
+ // Test creation
+ stm := NewSafeTokenMap()
+ if stm == nil {
+ t.Fatal("NewSafeTokenMap returned nil")
}
- tmpfile.Close()
- // Test loading tokens
- tm := NewTokenMap()
- err = tm.LoadTokensFromFile(tmpfile.Name())
- if err != nil {
- t.Fatalf("LoadTokensFromFile failed: %v", err)
+ // Test Set and Get
+ id := AccessID("test123")
+ hash := "testhash"
+
+ stm.Set(id, hash)
+
+ got, exists := stm.Get(id)
+ if !exists {
+ t.Error("Get returned false for existing key")
+ }
+ if got != hash {
+ t.Errorf("Get returned wrong hash. Want %s, got %s", hash, got)
}
- // Verify loaded data
- if hash, ok := tm["testuser"]; !ok || hash != "testhash" {
- t.Errorf("Expected hash 'testhash' for testuser, got %v", hash)
+ // Test non-existent key
+ _, exists = stm.Get("nonexistent")
+ if exists {
+ t.Error("Get returned true for non-existent key")
}
- if hash, ok := tm["user2"]; !ok || hash != "hash2" {
- t.Errorf("Expected hash 'hash2' for user2, got %v", hash)
+
+ // Test concurrent access to verify thread safety
+ t.Run("Concurrent access", func(t *testing.T) {
+ stm := NewSafeTokenMap()
+ const goroutines = 100
+ var wg sync.WaitGroup
+ wg.Add(goroutines * 2) // for both readers and writers
+
+ // Launch writers
+ for i := 0; i < goroutines; i++ {
+ go func(i int) {
+ defer wg.Done()
+ id := AccessID(fmt.Sprintf("test%d", i))
+ hash := fmt.Sprintf("hash%d", i)
+ stm.Set(id, hash)
+ }(i)
+ }
+
+ // Launch readers
+ for i := 0; i < goroutines; i++ {
+ go func(i int) {
+ defer wg.Done()
+ id := AccessID(fmt.Sprintf("test%d", i))
+ // Keep trying to read until we get the value or timeout
+ for j := 0; j < 1000; j++ {
+ if hash, exists := stm.Get(id); exists {
+ expected := fmt.Sprintf("hash%d", i)
+ if hash != expected {
+ t.Errorf("Got wrong hash for id %s. Want %s, got %s", id, expected, hash)
+ }
+ break
+ }
+ }
+ }(i)
+ }
+
+ wg.Wait()
+ })
+
+ // Test LoadFromFile
+ t.Run("LoadFromFile", func(t *testing.T) {
+ // Create a temporary CSV file for testing
+ tmpfile, err := os.CreateTemp("", "tokens*.csv")
+ if err != nil {
+ t.Fatalf("Failed to create temp file: %v", err)
+ }
+ defer os.Remove(tmpfile.Name())
+
+ // Write test data
+ testData := "access123,tester,testhash\naccess456,bot:deploy,hash2\n"
+ if _, err := tmpfile.Write([]byte(testData)); err != nil {
+ t.Fatalf("Failed to write test data: %v", err)
+ }
+ tmpfile.Close()
+
+ stm := NewSafeTokenMap()
+ err = stm.LoadFromFile(tmpfile.Name())
+ if err != nil {
+ t.Fatalf("LoadFromFile failed: %v", err)
+ }
+
+ // Verify loaded data
+ hash, exists := stm.Get(AccessID("access123"))
+ if !exists || hash != "testhash" {
+ t.Errorf("Expected hash 'testhash' for access123, got %v", hash)
+ }
+
+ // Test loading non-existent file
+ err = stm.LoadFromFile("nonexistent.csv")
+ if err == nil {
+ t.Error("Expected error when loading non-existent file")
+ }
+ })
+}
+
+func TestIdentityMapThreadSafety(t *testing.T) {
+ im := NewIdentityMap()
+ const goroutines = 100
+ var wg sync.WaitGroup
+ wg.Add(goroutines * 2) // for both registration and lookups
+
+ // Launch registrations
+ for i := 0; i < goroutines; i++ {
+ go func(i int) {
+ defer wg.Done()
+ id := AccessID(fmt.Sprintf("test%d", i))
+ name := FriendlyName(fmt.Sprintf("user%d", i))
+ im.Register(id, name)
+ }(i)
}
- // Test loading non-existent file
- err = tm.LoadTokensFromFile("nonexistent.csv")
- if err == nil {
- t.Error("Expected error when loading non-existent file")
+ // Launch lookups
+ for i := 0; i < goroutines; i++ {
+ go func(i int) {
+ defer wg.Done()
+ id := AccessID(fmt.Sprintf("test%d", i))
+ // Keep trying to read until we get the value or timeout
+ for j := 0; j < 1000; j++ {
+ // Use thread-safe access method instead of direct map access
+ im.mu.RLock()
+ name, exists := im.IDToName[id]
+ im.mu.RUnlock()
+
+ if exists {
+ expected := FriendlyName(fmt.Sprintf("user%d", i))
+ if name != expected {
+ t.Errorf("Got wrong name for id %s. Want %s, got %s", id, expected, name)
+ }
+ break
+ }
+ }
+ }(i)
}
+
+ wg.Wait()
}
diff --git a/internal/git/handler_test.go b/internal/git/handler_test.go
index f9b4cd7..95f1bc7 100644
--- a/internal/git/handler_test.go
+++ b/internal/git/handler_test.go
@@ -1,14 +1,11 @@
package git
import (
- "os"
"testing"
+
)
func TestGitHandler(t *testing.T) {
- dir, err := os.MkdirTemp("", "go-git-tests")
- if err != nil {
- t.Fatalf("Couldn't create a temp directory for tests: %s", err)
- }
+ dir := t.TempDir()
_ = GitHttpBackendHandler(dir, "git http-backend")
}
diff --git a/justfile b/justfile
index f4037b1..8043e7e 100644
--- a/justfile
+++ b/justfile
@@ -1,4 +1,4 @@
-TEMPDIR := `mktemp -d`
+TEMPDIR := `mktemp -d -p $(pwd)`
BUILDDIR := "_build"
ALL_VERSIONS := BUILDDIR / "ALL_VERSIONS"
NEW_VERSION := BUILDDIR / "NEW_VERSION"
@@ -96,7 +96,7 @@ run repo=(TEMPDIR):
test:
golangci-lint run
- go test -v -coverprofile={{ TEMPDIR }}/testcover.out ./...
+ TMPDIR=$PWD/testdata go test -v -coverprofile={{ TEMPDIR }}/testcover.out ./...
go tool cover -func={{ TEMPDIR }}/testcover.out
debug-test pkg func:
diff --git a/policy.csv b/policy.csv
deleted file mode 100644
index c3c632c..0000000
--- a/policy.csv
+++ /dev/null
@@ -1,3 +0,0 @@
-g, role:admin, role:maintainers
-g, uid:admin, role:admin
-g, uid:maintainer, role:maintainers