Skip to content

Commit 9cf4ea0

Browse files
barckcodeclaude
andcommitted
feat: add auth system with multi-tenant isolation and RBAC
Implement a complete authentication and authorization system: - Pluggable auth provider interface (noop, local) via AUTH_PROVIDER env var - Noop provider: default for self-hosted, auto-creates org/user, no login required - Local provider: email/password with bcrypt + JWT (HMAC-SHA256) tokens - Organization, User, Invite models with multi-tenant data isolation - Auth middleware injects claims into request context for all routes - OrgScope GORM scope applied to all handlers for tenant isolation - Role-based access control (admin/member) with IsAdmin helper - Admin password reset with crypto/rand temporary passwords - Invite system with SHA-256 hashed tokens + AES-256-GCM encrypted storage - Email validation and duplicate checking on invite creation - WebSocket auth via ?token= query parameter - Composite unique indexes (Team.Name, Settings.Key) scoped by org_id - LoadSettingsEnv scoped by org_id to prevent cross-tenant secret leakage - JWT_SECRET minimum 32 characters validation - Email case normalization across all auth flows - Multi-tenant mode with registration gating (MULTI_TENANT env var) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 54658fd commit 9cf4ea0

29 files changed

Lines changed: 1900 additions & 137 deletions

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.2.11
1+
0.3.0

cmd/api/main.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"syscall"
99

1010
"github.com/helmcode/agent-crew/internal/api"
11+
"github.com/helmcode/agent-crew/internal/auth"
1112
"github.com/helmcode/agent-crew/internal/models"
1213
"github.com/helmcode/agent-crew/internal/runtime"
1314
"github.com/helmcode/agent-crew/internal/scheduler"
@@ -60,7 +61,27 @@ func main() {
6061
listenAddr = ":" + port
6162
}
6263

63-
srv := api.NewServer(db, rt)
64+
// Auth provider.
65+
authCfg := auth.Config{
66+
JWTSecret: os.Getenv("JWT_SECRET"),
67+
JWTAccessExpiration: os.Getenv("JWT_ACCESS_EXPIRATION"),
68+
JWTRefreshExpiration: os.Getenv("JWT_REFRESH_EXPIRATION"),
69+
MultiTenant: os.Getenv("MULTI_TENANT") == "true",
70+
}
71+
authProvider, err := auth.NewProvider(os.Getenv("AUTH_PROVIDER"), db, authCfg)
72+
if err != nil {
73+
slog.Error("failed to initialize auth provider", "error", err)
74+
os.Exit(1)
75+
}
76+
slog.Info("auth provider initialized", "provider", authProvider.ProviderName())
77+
78+
srv := api.NewServer(db, rt, authProvider)
79+
80+
// Configure multi-tenant mode.
81+
if os.Getenv("MULTI_TENANT") == "true" {
82+
srv.SetMultiTenant(true)
83+
slog.Info("multi-tenant mode enabled")
84+
}
6485

6586
// Configure webhook concurrency limit.
6687
if v := os.Getenv("WEBHOOK_MAX_CONCURRENT"); v != "" {

cmd/testserver/main.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"syscall"
1414

1515
"github.com/helmcode/agent-crew/internal/api"
16+
"github.com/helmcode/agent-crew/internal/auth"
1617
"github.com/helmcode/agent-crew/internal/models"
1718
"github.com/helmcode/agent-crew/internal/runtime"
1819
)
@@ -89,7 +90,13 @@ func main() {
8990
listenAddr = ":3333"
9091
}
9192

92-
srv := api.NewServer(db, &mockRuntime{})
93+
noopAuth, err := auth.NewNoopProvider(db)
94+
if err != nil {
95+
slog.Error("failed to initialize noop auth provider", "error", err)
96+
os.Exit(1)
97+
}
98+
99+
srv := api.NewServer(db, &mockRuntime{}, noopAuth)
93100

94101
go func() {
95102
if err := srv.Listen(listenAddr); err != nil {

go.mod

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ require (
77
github.com/docker/go-connections v0.6.0
88
github.com/gofiber/contrib/websocket v1.3.4
99
github.com/gofiber/fiber/v2 v2.52.11
10+
github.com/golang-jwt/jwt/v5 v5.3.1
1011
github.com/google/uuid v1.6.0
1112
github.com/nats-io/nats.go v1.48.0
13+
golang.org/x/crypto v0.48.0
1214
gopkg.in/yaml.v3 v3.0.1
1315
gorm.io/driver/sqlite v1.6.0
1416
gorm.io/gorm v1.31.1
@@ -75,12 +77,11 @@ require (
7577
go.opentelemetry.io/otel/trace v1.40.0 // indirect
7678
go.yaml.in/yaml/v2 v2.4.3 // indirect
7779
go.yaml.in/yaml/v3 v3.0.4 // indirect
78-
golang.org/x/crypto v0.47.0 // indirect
7980
golang.org/x/net v0.49.0 // indirect
8081
golang.org/x/oauth2 v0.30.0 // indirect
81-
golang.org/x/sys v0.40.0 // indirect
82-
golang.org/x/term v0.39.0 // indirect
83-
golang.org/x/text v0.33.0 // indirect
82+
golang.org/x/sys v0.41.0 // indirect
83+
golang.org/x/term v0.40.0 // indirect
84+
golang.org/x/text v0.34.0 // indirect
8485
golang.org/x/time v0.14.0 // indirect
8586
google.golang.org/protobuf v1.36.11 // indirect
8687
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect

go.sum

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ github.com/gofiber/fiber/v2 v2.52.11 h1:5f4yzKLcBcF8ha1GQTWB+mpblWz3Vz6nSAbTL31H
5555
github.com/gofiber/fiber/v2 v2.52.11/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
5656
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
5757
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
58+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
59+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
5860
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
5961
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
6062
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -194,12 +196,12 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
194196
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
195197
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
196198
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
197-
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
198-
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
199+
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
200+
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
199201
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
200202
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
201-
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
202-
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
203+
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
204+
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
203205
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
204206
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
205207
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -220,22 +222,22 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
220222
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
221223
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
222224
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
223-
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
224-
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
225-
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
226-
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
225+
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
226+
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
227+
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
228+
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
227229
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
228230
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
229-
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
230-
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
231+
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
232+
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
231233
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
232234
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
233235
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
234236
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
235237
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
236238
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
237-
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
238-
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
239+
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
240+
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
239241
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
240242
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
241243
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

internal/api/api_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"testing"
1111

12+
"github.com/helmcode/agent-crew/internal/auth"
1213
"github.com/helmcode/agent-crew/internal/models"
1314
"github.com/helmcode/agent-crew/internal/runtime"
1415
)
@@ -94,7 +95,11 @@ func setupTestServer(t *testing.T) (*Server, *mockRuntime) {
9495
t.Fatalf("InitDB: %v", err)
9596
}
9697
mock := &mockRuntime{}
97-
srv := NewServer(db, mock)
98+
noopAuth, err := auth.NewNoopProvider(db)
99+
if err != nil {
100+
t.Fatalf("NewNoopProvider: %v", err)
101+
}
102+
srv := NewServer(db, mock, noopAuth)
98103
return srv, mock
99104
}
100105

internal/api/context.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package api
2+
3+
import (
4+
"github.com/gofiber/fiber/v2"
5+
"gorm.io/gorm"
6+
7+
"github.com/helmcode/agent-crew/internal/models"
8+
)
9+
10+
// GetOrgID extracts the organization ID from the request context.
11+
func GetOrgID(c *fiber.Ctx) string {
12+
v, _ := c.Locals("org_id").(string)
13+
return v
14+
}
15+
16+
// GetUserID extracts the user ID from the request context.
17+
func GetUserID(c *fiber.Ctx) string {
18+
v, _ := c.Locals("user_id").(string)
19+
return v
20+
}
21+
22+
// GetRole extracts the user role from the request context.
23+
func GetRole(c *fiber.Ctx) string {
24+
v, _ := c.Locals("role").(string)
25+
return v
26+
}
27+
28+
// IsAdmin returns true if the authenticated user has the admin role.
29+
func IsAdmin(c *fiber.Ctx) bool {
30+
return GetRole(c) == models.UserRoleAdmin
31+
}
32+
33+
// OrgScope returns a GORM scope that filters queries by the request's org_id.
34+
func OrgScope(c *fiber.Ctx) func(db *gorm.DB) *gorm.DB {
35+
return func(db *gorm.DB) *gorm.DB {
36+
return db.Where("org_id = ?", GetOrgID(c))
37+
}
38+
}

internal/api/handlers_agents.go

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ import (
1818
func (s *Server) ListAgents(c *fiber.Ctx) error {
1919
teamID := c.Params("id")
2020

21-
// Verify team exists.
21+
// Verify team exists and belongs to org.
2222
var team models.Team
23-
if err := s.db.First(&team, "id = ?", teamID).Error; err != nil {
23+
if err := s.db.Scopes(OrgScope(c)).First(&team, "id = ?", teamID).Error; err != nil {
2424
return fiber.NewError(fiber.StatusNotFound, "team not found")
2525
}
2626

@@ -36,6 +36,12 @@ func (s *Server) GetAgent(c *fiber.Ctx) error {
3636
teamID := c.Params("id")
3737
agentID := c.Params("agentId")
3838

39+
// Verify team belongs to org.
40+
var team models.Team
41+
if err := s.db.Scopes(OrgScope(c)).First(&team, "id = ?", teamID).Error; err != nil {
42+
return fiber.NewError(fiber.StatusNotFound, "team not found")
43+
}
44+
3945
var agent models.Agent
4046
if err := s.db.Where("id = ? AND team_id = ?", agentID, teamID).First(&agent).Error; err != nil {
4147
return fiber.NewError(fiber.StatusNotFound, "agent not found")
@@ -48,7 +54,7 @@ func (s *Server) CreateAgent(c *fiber.Ctx) error {
4854
teamID := c.Params("id")
4955

5056
var team models.Team
51-
if err := s.db.First(&team, "id = ?", teamID).Error; err != nil {
57+
if err := s.db.Scopes(OrgScope(c)).First(&team, "id = ?", teamID).Error; err != nil {
5258
return fiber.NewError(fiber.StatusNotFound, "team not found")
5359
}
5460

@@ -107,6 +113,7 @@ func (s *Server) CreateAgent(c *fiber.Ctx) error {
107113

108114
agent := models.Agent{
109115
ID: uuid.New().String(),
116+
OrgID: GetOrgID(c),
110117
TeamID: teamID,
111118
Name: req.Name,
112119
Role: role,
@@ -133,6 +140,12 @@ func (s *Server) UpdateAgent(c *fiber.Ctx) error {
133140
teamID := c.Params("id")
134141
agentID := c.Params("agentId")
135142

143+
// Verify team belongs to org.
144+
var team models.Team
145+
if err := s.db.Scopes(OrgScope(c)).First(&team, "id = ?", teamID).Error; err != nil {
146+
return fiber.NewError(fiber.StatusNotFound, "team not found")
147+
}
148+
136149
var agent models.Agent
137150
if err := s.db.Where("id = ? AND team_id = ?", agentID, teamID).First(&agent).Error; err != nil {
138151
return fiber.NewError(fiber.StatusNotFound, "agent not found")
@@ -228,7 +241,7 @@ func (s *Server) InstallAgentSkill(c *fiber.Ctx) error {
228241

229242
// Find team and verify it's running.
230243
var team models.Team
231-
if err := s.db.First(&team, "id = ?", teamID).Error; err != nil {
244+
if err := s.db.Scopes(OrgScope(c)).First(&team, "id = ?", teamID).Error; err != nil {
232245
return fiber.NewError(fiber.StatusNotFound, "team not found")
233246
}
234247
if team.Status != models.TeamStatusRunning {
@@ -404,7 +417,7 @@ func (s *Server) GetInstructions(c *fiber.Ctx) error {
404417
agentID := c.Params("agentId")
405418

406419
var team models.Team
407-
if err := s.db.First(&team, "id = ?", teamID).Error; err != nil {
420+
if err := s.db.Scopes(OrgScope(c)).First(&team, "id = ?", teamID).Error; err != nil {
408421
return fiber.NewError(fiber.StatusNotFound, "team not found")
409422
}
410423
if team.Status != models.TeamStatusRunning {
@@ -440,7 +453,7 @@ func (s *Server) UpdateInstructions(c *fiber.Ctx) error {
440453
agentID := c.Params("agentId")
441454

442455
var team models.Team
443-
if err := s.db.First(&team, "id = ?", teamID).Error; err != nil {
456+
if err := s.db.Scopes(OrgScope(c)).First(&team, "id = ?", teamID).Error; err != nil {
444457
return fiber.NewError(fiber.StatusNotFound, "team not found")
445458
}
446459
if team.Status != models.TeamStatusRunning {
@@ -538,6 +551,12 @@ func (s *Server) DeleteAgent(c *fiber.Ctx) error {
538551
teamID := c.Params("id")
539552
agentID := c.Params("agentId")
540553

554+
// Verify team belongs to org.
555+
var team models.Team
556+
if err := s.db.Scopes(OrgScope(c)).First(&team, "id = ?", teamID).Error; err != nil {
557+
return fiber.NewError(fiber.StatusNotFound, "team not found")
558+
}
559+
541560
var agent models.Agent
542561
if err := s.db.Where("id = ? AND team_id = ?", agentID, teamID).First(&agent).Error; err != nil {
543562
return fiber.NewError(fiber.StatusNotFound, "agent not found")

0 commit comments

Comments
 (0)