diff --git a/apps/api/internal/auth/service.go b/apps/api/internal/auth/service.go index 7da6882..9d1d473 100644 --- a/apps/api/internal/auth/service.go +++ b/apps/api/internal/auth/service.go @@ -67,8 +67,13 @@ type Service struct { resetTokenStore *store.PasswordResetTokenStore accountStore *store.AccountStore apiTokenStore *store.ApiTokenStore + workspaceStore *store.WorkspaceStore // optional: enforces workspace-token scope } +// SetWorkspaceStore wires the workspace store used to enforce that a +// workspace-scoped API token only reaches its own workspace. Optional. +func (s *Service) SetWorkspaceStore(ws *store.WorkspaceStore) { s.workspaceStore = ws } + func NewService(userStore *store.UserStore, sessionStore *store.SessionStore, resetTokenStore *store.PasswordResetTokenStore) *Service { return &Service{userStore: userStore, sessionStore: sessionStore, resetTokenStore: resetTokenStore} } @@ -272,19 +277,37 @@ func (s *Service) ActiveUserByID(ctx context.Context, id uuid.UUID) (*model.User // if the value isn't a recognized token (not an error — callers should fall // back to other bearer interpretations). func (s *Service) UserFromAPIToken(ctx context.Context, plain string) (*model.User, error) { + user, _, err := s.UserFromAPITokenScoped(ctx, plain) + return user, err +} + +// UserFromAPITokenScoped resolves a bearer token to its user and also returns +// the token record, so callers can enforce a workspace-scoped token's scope. +func (s *Service) UserFromAPITokenScoped(ctx context.Context, plain string) (*model.User, *model.ApiToken, error) { if s.apiTokenStore == nil { - return nil, nil + return nil, nil, nil } tok, err := s.apiTokenStore.GetActiveByHash(ctx, store.HashToken(plain)) if err != nil || tok == nil { - return nil, nil + return nil, nil, nil } user, err := s.ActiveUserByID(ctx, tok.UserID) if err != nil || user == nil { - return nil, nil + return nil, nil, nil } _ = s.apiTokenStore.UpdateLastUsed(ctx, tok.ID) - return user, nil + return user, tok, nil +} + +// SlugMatchesWorkspace reports whether the workspace addressed by slug is the +// given workspace id. Used to keep a workspace-scoped token inside its own +// workspace. Returns false if the store isn't wired or the slug is unknown. +func (s *Service) SlugMatchesWorkspace(ctx context.Context, slug string, workspaceID uuid.UUID) bool { + if s.workspaceStore == nil || slug == "" { + return false + } + w, err := s.workspaceStore.GetBySlug(ctx, slug) + return err == nil && w != nil && w.ID == workspaceID } func (s *Service) UpdateProfile(ctx context.Context, u *model.User) error { diff --git a/apps/api/internal/handler/token_scope_test.go b/apps/api/internal/handler/token_scope_test.go new file mode 100644 index 0000000..4c26928 --- /dev/null +++ b/apps/api/internal/handler/token_scope_test.go @@ -0,0 +1,52 @@ +package handler_test + +import ( + "context" + "net/http" + "testing" + + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/require" +) + +func bearer(token string) http.Header { + h := http.Header{} + h.Set("Authorization", "Bearer "+token) + return h +} + +// A workspace-scoped API token must only reach its own workspace's routes. +func TestWorkspaceTokenScope(t *testing.T) { + ts := testutil.NewTestServer(t) + db := ts.DB + ctx := context.Background() + + user := testutil.CreateUser(t, db) + wsA := testutil.CreateWorkspace(t, db, user.ID) + wsB := testutil.CreateWorkspace(t, db, user.ID) + + tokens := store.NewApiTokenStore(db) + wsToken, err := tokens.CreateForWorkspace(ctx, user.ID, wsA.ID, "ci", "", nil) + require.NoError(t, err) + + // Allowed: its own workspace. + rr := ts.DoWithHeaders("GET", "/api/workspaces/"+wsA.Slug+"/", nil, bearer(wsToken)) + require.Equal(t, http.StatusOK, rr.Code, "token should reach its own workspace") + + // Denied: a different workspace the owner also belongs to. + rr = ts.DoWithHeaders("GET", "/api/workspaces/"+wsB.Slug+"/", nil, bearer(wsToken)) + require.Equal(t, http.StatusForbidden, rr.Code, "token must not reach another workspace") + + // Denied: a non-workspace route. + rr = ts.DoWithHeaders("GET", "/api/users/me/", nil, bearer(wsToken)) + require.Equal(t, http.StatusForbidden, rr.Code, "workspace token must not reach /users/me") + + // A personal token (no workspace scope) still works everywhere. + personal, err := tokens.Create(ctx, user.ID, "personal", "", nil) + require.NoError(t, err) + rr = ts.DoWithHeaders("GET", "/api/users/me/", nil, bearer(personal)) + require.Equal(t, http.StatusOK, rr.Code, "personal token should still reach /users/me") + rr = ts.DoWithHeaders("GET", "/api/workspaces/"+wsB.Slug+"/", nil, bearer(personal)) + require.Equal(t, http.StatusOK, rr.Code, "personal token should still reach any workspace") +} diff --git a/apps/api/internal/middleware/auth.go b/apps/api/internal/middleware/auth.go index 30d7f21..3eec218 100644 --- a/apps/api/internal/middleware/auth.go +++ b/apps/api/internal/middleware/auth.go @@ -48,7 +48,15 @@ func RequireAuth(authSvc *auth.Service, log *slog.Logger) gin.HandlerFunc { } if authHeader := c.GetHeader("Authorization"); len(authHeader) > 7 && strings.EqualFold(authHeader[:7], "bearer ") { bearer := strings.TrimSpace(authHeader[7:]) - if user, err := authSvc.UserFromAPIToken(ctx, bearer); err == nil && user != nil { + if user, tok, err := authSvc.UserFromAPITokenScoped(ctx, bearer); err == nil && user != nil { + // A workspace-scoped token may only reach its own workspace's + // routes. Deny anything else (other workspaces, /users/me, + // instance admin, minting more tokens). + if tok != nil && tok.WorkspaceID != nil && + !authSvc.SlugMatchesWorkspace(ctx, c.Param("slug"), *tok.WorkspaceID) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Token is scoped to a different workspace"}) + return + } c.Set(UserContextKey, user) c.Next() return diff --git a/apps/api/internal/router/router.go b/apps/api/internal/router/router.go index 39e9da9..28a5960 100644 --- a/apps/api/internal/router/router.go +++ b/apps/api/internal/router/router.go @@ -106,6 +106,7 @@ func New(cfg Config) (*gin.Engine, *service.ImporterService) { authSvc := auth.NewService(userStore, sessionStore, passwordResetTokenStore) authSvc.SetAccountStore(accountStore) authSvc.SetApiTokenStore(apiTokenStore) + authSvc.SetWorkspaceStore(workspaceStore) accountSvc := service.NewAccountService(userStore, sessionStore, store.NewEmailChangeRequestStore(cfg.DB), cfg.MagicCodeSecret) appBaseURL := cfg.AppBaseURL if appBaseURL == "" {