@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
13+ "github.com/fleetdm/fleet/v4/server/contexts/viewer"
1314 "github.com/fleetdm/fleet/v4/server/fleet"
1415 common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
1516 "github.com/jmoiron/sqlx"
@@ -36,6 +37,12 @@ func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User
3637 return nil , ctxerr .Wrap (ctx , err , "validate role" )
3738 }
3839
40+ var authorID * uint
41+ if vc , ok := viewer .FromContext (ctx ); ok {
42+ id := vc .UserID ()
43+ authorID = & id
44+ }
45+
3946 err := ds .withTx (ctx , func (tx sqlx.ExtContext ) error {
4047 sqlStatement := `
4148 INSERT INTO users (
@@ -82,6 +89,13 @@ func (ds *Datastore) NewUser(ctx context.Context, user *fleet.User) (*fleet.User
8289 if err := saveTeamsForUserDB (ctx , tx , user ); err != nil {
8390 return err
8491 }
92+
93+ if user .APIEndpoints != nil {
94+ if err := replaceUserAPIEndpoints (ctx , tx , user .ID , authorID , user .APIEndpoints ); err != nil {
95+ return err
96+ }
97+ }
98+
8599 return nil
86100 })
87101 if err != nil {
@@ -111,6 +125,10 @@ func (ds *Datastore) findUser(ctx context.Context, searchCol string, searchVal i
111125 return nil , ctxerr .Wrap (ctx , err , "load teams" )
112126 }
113127
128+ if err := ds .loadAPIEndpointsForUsers (ctx , []* fleet.User {user }); err != nil {
129+ return nil , ctxerr .Wrap (ctx , err , "load api endpoints" )
130+ }
131+
114132 // When SSO is enabled, we can ignore forced password resets
115133 // However, we want to leave the db untouched, to cover cases where SSO is toggled
116134 if user .SSOEnabled {
@@ -174,6 +192,10 @@ func (ds *Datastore) ListUsers(ctx context.Context, opt fleet.UserListOptions) (
174192 return nil , ctxerr .Wrap (ctx , err , "load teams" )
175193 }
176194
195+ if err := ds .loadAPIEndpointsForUsers (ctx , users ); err != nil {
196+ return nil , ctxerr .Wrap (ctx , err , "load api endpoints" )
197+ }
198+
177199 return users , nil
178200}
179201
@@ -230,15 +252,25 @@ func (ds *Datastore) deletedUserByID(ctx context.Context, id uint) (*fleet.User,
230252}
231253
232254func (ds * Datastore ) SaveUser (ctx context.Context , user * fleet.User ) error {
255+ var authorID * uint
256+ if vc , ok := viewer .FromContext (ctx ); ok {
257+ id := vc .UserID ()
258+ authorID = & id
259+ }
233260 return ds .withTx (ctx , func (tx sqlx.ExtContext ) error {
234- return saveUserDB (ctx , tx , user )
261+ return saveUserDB (ctx , tx , user , authorID )
235262 })
236263}
237264
238265func (ds * Datastore ) SaveUsers (ctx context.Context , users []* fleet.User ) error {
266+ var authorID * uint
267+ if vc , ok := viewer .FromContext (ctx ); ok {
268+ id := vc .UserID ()
269+ authorID = & id
270+ }
239271 return ds .withTx (ctx , func (tx sqlx.ExtContext ) error {
240272 for _ , user := range users {
241- err := saveUserDB (ctx , tx , user )
273+ err := saveUserDB (ctx , tx , user , authorID )
242274 if err != nil {
243275 return err
244276 }
@@ -247,7 +279,7 @@ func (ds *Datastore) SaveUsers(ctx context.Context, users []*fleet.User) error {
247279 })
248280}
249281
250- func saveUserDB (ctx context.Context , tx sqlx.ExtContext , user * fleet.User ) error {
282+ func saveUserDB (ctx context.Context , tx sqlx.ExtContext , user * fleet.User , authorID * uint ) error {
251283 if err := fleet .ValidateRole (user .GlobalRole , user .Teams ); err != nil {
252284 return ctxerr .Wrap (ctx , err , "validate role" )
253285 }
@@ -301,6 +333,57 @@ func saveUserDB(ctx context.Context, tx sqlx.ExtContext, user *fleet.User) error
301333 return err
302334 }
303335
336+ if user .APIEndpoints != nil {
337+ if err := replaceUserAPIEndpoints (ctx , tx , user .ID , authorID , user .APIEndpoints ); err != nil {
338+ return err
339+ }
340+ }
341+
342+ return nil
343+ }
344+
345+ // loadAPIEndpointsForUsers loads api_endpoints for any API-only users in the slice.
346+ // Non-API-only users are left untouched.
347+ func (ds * Datastore ) loadAPIEndpointsForUsers (ctx context.Context , users []* fleet.User ) error {
348+ var apiOnlyIDs []uint
349+ for _ , u := range users {
350+ if u .APIOnly {
351+ apiOnlyIDs = append (apiOnlyIDs , u .ID )
352+ }
353+ }
354+ if len (apiOnlyIDs ) == 0 {
355+ return nil
356+ }
357+
358+ query , args , err := sqlx .In (
359+ `SELECT user_id, method, path FROM user_api_endpoints WHERE user_id IN (?) ORDER BY method, path` ,
360+ apiOnlyIDs ,
361+ )
362+ if err != nil {
363+ return ctxerr .Wrap (ctx , err , "build load api endpoints query" )
364+ }
365+
366+ var rows []struct {
367+ UserID uint `db:"user_id"`
368+ Method string `db:"method"`
369+ Path string `db:"path"`
370+ }
371+ if err := sqlx .SelectContext (ctx , ds .reader (ctx ), & rows , query , args ... ); err != nil {
372+ return ctxerr .Wrap (ctx , err , "load api endpoints for users" )
373+ }
374+
375+ byUserID := make (map [uint ][]fleet.APIEndpointRef , len (apiOnlyIDs ))
376+ for _ , row := range rows {
377+ byUserID [row .UserID ] = append (byUserID [row .UserID ], fleet.APIEndpointRef {
378+ Method : row .Method ,
379+ Path : row .Path ,
380+ })
381+ }
382+ for _ , u := range users {
383+ if u .APIOnly {
384+ u .APIEndpoints = byUserID [u .ID ] // nil when no endpoints assigned
385+ }
386+ }
304387 return nil
305388}
306389
@@ -442,6 +525,11 @@ func (ds *Datastore) DeleteUserIfNotLastAdmin(ctx context.Context, id uint) erro
442525// It uses SELECT ... FOR UPDATE to prevent concurrent requests from bypassing
443526// the check (TOCTOU race condition).
444527func (ds * Datastore ) SaveUserIfNotLastAdmin (ctx context.Context , user * fleet.User ) error {
528+ var authorID * uint
529+ if vc , ok := viewer .FromContext (ctx ); ok {
530+ id := vc .UserID ()
531+ authorID = & id
532+ }
445533 return ds .withTx (ctx , func (tx sqlx.ExtContext ) error {
446534 // Lock the admin rows to prevent concurrent modifications.
447535 var count int
@@ -453,7 +541,7 @@ func (ds *Datastore) SaveUserIfNotLastAdmin(ctx context.Context, user *fleet.Use
453541 return fleet .ErrLastGlobalAdmin
454542 }
455543
456- return saveUserDB (ctx , tx , user )
544+ return saveUserDB (ctx , tx , user , authorID )
457545 })
458546}
459547
@@ -507,3 +595,45 @@ func (ds *Datastore) UserSettings(ctx context.Context, userID uint) (*fleet.User
507595 }
508596 return settings , nil
509597}
598+
599+ // replaceUserAPIEndpoints replaces all API endpoint permissions for the given user.
600+ // authorID is the user who made the change; pass nil when there is no viewer context
601+ // (e.g., system-level operations), in which case author_id is stored as NULL.
602+ func replaceUserAPIEndpoints (ctx context.Context , tx sqlx.ExtContext , userID uint , authorID * uint , endpoints []fleet.APIEndpointRef ) error {
603+ if _ , err := tx .ExecContext (ctx , `DELETE FROM user_api_endpoints WHERE user_id = ?` , userID ); err != nil {
604+ return ctxerr .Wrap (ctx , err , "delete user api endpoints" )
605+ }
606+ if len (endpoints ) == 0 {
607+ return nil
608+ }
609+ placeholders := strings .Repeat ("(?, ?, ?, ?)," , len (endpoints ))
610+ placeholders = placeholders [:len (placeholders )- 1 ] // trim trailing comma
611+ args := make ([]any , 0 , len (endpoints )* 4 )
612+ for _ , ep := range endpoints {
613+ args = append (args , userID , ep .Path , ep .Method , authorID )
614+ }
615+ _ , err := tx .ExecContext (ctx ,
616+ `INSERT INTO user_api_endpoints (user_id, path, method, author_id) VALUES ` + placeholders ,
617+ args ... ,
618+ )
619+ return ctxerr .Wrap (ctx , err , "insert user api endpoints" )
620+ }
621+
622+ // ListUserAPIEndpoints returns all API endpoint permissions assigned to the given user.
623+ func (ds * Datastore ) ListUserAPIEndpoints (ctx context.Context , userID uint ) ([]fleet.APIEndpoint , error ) {
624+ var rows []struct {
625+ Method string `db:"method"`
626+ Path string `db:"path"`
627+ }
628+ if err := sqlx .SelectContext (ctx , ds .reader (ctx ), & rows ,
629+ `SELECT method, path FROM user_api_endpoints WHERE user_id = ? ORDER BY method, path` ,
630+ userID ,
631+ ); err != nil {
632+ return nil , ctxerr .Wrap (ctx , err , "list user api endpoints" )
633+ }
634+ endpoints := make ([]fleet.APIEndpoint , 0 , len (rows ))
635+ for _ , row := range rows {
636+ endpoints = append (endpoints , fleet .NewAPIEndpointFromTpl (row .Method , row .Path ))
637+ }
638+ return endpoints , nil
639+ }
0 commit comments