Skip to content

Commit

Permalink
feat(auth): Add group support #235 (#283)
Browse files Browse the repository at this point in the history
* feat(auth): Add group support #235

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Update database migration values

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Update json-schema

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Update default auth handler

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Add new column in SQL query to retrieve a task template

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Return an empty array when the user has not group

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Update groups-auth format + documentation

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Add additional resolver groups to a task and a subtask

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Add resolver groups support to list tasks

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Add watcher_groups feature

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Update subtask README + json schema

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Update database migration values

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Update CFG_GROUPS_AUTH in docker-compose

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* Update 007_user_groups.sql

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* fix(subtask): inject requester's groups into stepContext

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* fix: typo

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* fix: remove jsonb_path_ops and use the right context

Signed-off-by: Simon Delberghe <open-source@orandin.fr>

* fix(): linters

Co-authored-by: Romain Beuque <556072+rbeuque74@users.noreply.github.com>
  • Loading branch information
orandin and rbeuque74 authored Jul 29, 2022
1 parent 79ceac2 commit 8fe2adf
Show file tree
Hide file tree
Showing 31 changed files with 379 additions and 69 deletions.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,9 +224,10 @@ A process that can be executed by µTask is modelled as a `task template`: it is

The user that creates a task is called `requester`, and the user that executes it is called `resolver`. Both can be the same user in some scenarios.

A user can be allowed to resolve a task in three ways:
A user can be allowed to resolve a task in four ways:
- the user is included in the global configuration's list of `admin_usernames`
- the user is included in the task's template list of `allowed_resolver_usernames`
- the user is in a group that is included in the task's template list of `allowed_resolver_groups`
- the user is included in the task `resolver_usernames` list

### Value Templating
Expand Down Expand Up @@ -269,6 +270,7 @@ The following templating functions are available:

### Advanced properties

- `allowed_resolver_groups`: a list of groups with the right to resolve a task based on this template
- `allowed_resolver_usernames`: a list of usernames with the right to resolve a task based on this template
- `allow_all_resolver_usernames`: boolean (default: false): when true, any user can execute a task based on this template
- `auto_runnable`; boolean (default: false): when true, the task will be executed directly after being created, IF the requester is an accepted resolver or `allow_all_resolver_usernames` is true
Expand Down Expand Up @@ -797,7 +799,7 @@ type InitializerPlugin interface {

As of version `v1.0.0`, this is meant to give you access to two features:
- `service.Store` exposes the `RegisterProvider(name string, f configstore.Provider)` method that allow you to plug different data sources for you configuration, which are not available by default in the main runtime
- `service.Server` exposes the `WithAuth(authProvider func(*http.Request) (string, error))` method, where you can provide a custom source of authentication and authorization based on the incoming http requests
- `service.Server` exposes the `WithAuth(authProvider func(*http.Request) (string, error))` and `WithGroupAuth(groupAuthProvider func(*http.Request) (string, []string, error))` methods, where you can provide a custom source of authentication and authorization based on the incoming http requests

If you develop more than one initialization plugin, they will all be loaded in alphabetical order. You might want to provide a default initialization, plus more specific behaviour under certain scenarios.

Expand Down
2 changes: 1 addition & 1 deletion api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ func TestPagination(t *testing.T) {
cnt := 20
var midTask task.Task
for i := 0; i < cnt; i++ {
tsk, err := task.Create(dbp, tmpl, regularUser, nil, nil, map[string]interface{}{"id": strconv.Itoa(i)}, nil, nil)
tsk, err := task.Create(dbp, tmpl, regularUser, nil, nil, nil, nil, nil, map[string]interface{}{"id": strconv.Itoa(i)}, nil, nil)
if err != nil {
t.Fatal(err)
}
Expand Down
3 changes: 2 additions & 1 deletion api/handler/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type createBatchIn struct {
Inputs []map[string]interface{} `json:"inputs" binding:"required"`
Comment string `json:"comment"`
WatcherUsernames []string `json:"watcher_usernames"`
WatcherGroups []string `json:"watcher_groups"`
Tags map[string]string `json:"tags"`
}

Expand Down Expand Up @@ -61,7 +62,7 @@ func CreateBatch(c *gin.Context, in *createBatchIn) (*task.Batch, error) {
return nil, err
}

_, err = taskutils.CreateTask(c, dbp, tt, in.WatcherUsernames, []string{}, input, b, in.Comment, nil, in.Tags)
_, err = taskutils.CreateTask(c, dbp, tt, in.WatcherUsernames, in.WatcherGroups, []string{}, []string{}, input, b, in.Comment, nil, in.Tags)
if err != nil {
dbp.Rollback()
return nil, err
Expand Down
14 changes: 10 additions & 4 deletions api/handler/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ type createTaskIn struct {
Input map[string]interface{} `json:"input" binding:"required"`
Comment string `json:"comment"`
WatcherUsernames []string `json:"watcher_usernames"`
WatcherGroups []string `json:"watcher_groups"`
ResolverUsernames []string `json:"resolver_usernames"`
ResolverGroups []string `json:"resolver_groups"`
Delay *string `json:"delay"`
Tags map[string]string `json:"tags"`
}
Expand Down Expand Up @@ -63,8 +65,8 @@ func CreateTask(c *gin.Context, in *createTaskIn) (*task.Task, error) {
return nil, err
}

if len(in.ResolverUsernames) > 0 {
// if user is neither admin nor template owner, prevent setting the resolver_usernames
if len(in.ResolverUsernames) > 0 || len(in.ResolverGroups) > 0 {
// if user is neither admin nor template owner, prevent setting the resolver_usernames or resolver_groups
// as the template might have defined a limited set of users that can resolve the task.
// We need to be sure that the requester will not grant himself (or anybody else) as resolver
// and bypass the resolver restriction set on the task_template.
Expand All @@ -74,11 +76,11 @@ func CreateTask(c *gin.Context, in *createTaskIn) (*task.Task, error) {
templateOwner := auth.IsTemplateOwner(c, tt) == nil

if !admin && !templateOwner {
return nil, errors.Forbiddenf("resolver_usernames can't be set by a regular user, you need to be owner of the template, or admin")
return nil, errors.Forbiddenf("resolver_usernames and resolver_groups can't be set by a regular user, you need to be owner of the template, or admin")
}
}

t, err := taskutils.CreateTask(c, dbp, tt, in.WatcherUsernames, in.ResolverUsernames, in.Input, nil, in.Comment, in.Delay, in.Tags)
t, err := taskutils.CreateTask(c, dbp, tt, in.WatcherUsernames, in.WatcherGroups, in.ResolverUsernames, in.ResolverGroups, in.Input, nil, in.Comment, in.Delay, in.Tags)
if err != nil {
dbp.Rollback()
return nil, err
Expand Down Expand Up @@ -167,9 +169,11 @@ func ListTasks(c *gin.Context, in *listTasksIn) (t []*task.Task, err error) {
filter.RequesterUser = user
case taskTypeResolvable:
filter.PotentialResolverUser = user
filter.PotentialResolverGroups = auth.GetGroups(c)
case taskTypeAll:
if err2 := auth.IsAdmin(c); err2 != nil {
filter.RequesterOrPotentialResolverUser = user
filter.RequesterOrPotentialResolverGroups = auth.GetGroups(c)
}
default:
return nil, errors.BadRequestf("Unknown type for listing: '%s'. Was expecting '%s', '%s' or '%s'", in.Type, taskTypeOwn, taskTypeResolvable, taskTypeAll)
Expand Down Expand Up @@ -257,6 +261,7 @@ type updateTaskIn struct {
PublicID string `path:"id,required"`
Input map[string]interface{} `json:"input"`
WatcherUsernames []string `json:"watcher_usernames"`
WatcherGroups []string `json:"watcher_groups"`
Tags map[string]string `json:"tags"`
}

Expand Down Expand Up @@ -318,6 +323,7 @@ func UpdateTask(c *gin.Context, in *updateTaskIn) (*task.Task, error) {

t.SetInput(clearInput)
t.SetWatcherUsernames(in.WatcherUsernames)
t.SetWatcherGroups(in.WatcherGroups)

// validate read-only tags
v, readOnlyTagUpdated := in.Tags[constants.SubtaskTagParentTaskID]
Expand Down
1 change: 1 addition & 0 deletions api/handler/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/gin-gonic/gin"
"github.com/loopfz/gadgeto/zesty"

"github.com/ovh/utask"
"github.com/ovh/utask/models/tasktemplate"
"github.com/ovh/utask/pkg/auth"
Expand Down
19 changes: 19 additions & 0 deletions api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,22 @@ func authMiddleware(authProvider func(*http.Request) (string, error)) func(c *gi
}
return func(c *gin.Context) { c.Next() }
}

func groupAuthMiddleware(authProvider func(*http.Request) (string, []string, error)) func(c *gin.Context) {
if authProvider != nil {
return func(c *gin.Context) {
user, groups, err := authProvider(c.Request)
if err != nil {
if errors.IsUnauthorized(err) {
c.Header("WWW-Authenticate", `Basic realm="Authorization Required"`)
}
_ = c.AbortWithError(http.StatusUnauthorized, err)
return
}
c.Set(auth.IdentityProviderCtxKey, user)
c.Set(auth.GroupProviderCtxKey, groups)
c.Next()
}
}
return func(c *gin.Context) { c.Next() }
}
26 changes: 21 additions & 5 deletions api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ func (s *Server) WithAuth(authProvider func(*http.Request) (string, error)) {
}
}

// WithAuthGroup configures the Server's auth group middleware
// it receives an groupAuthProvider function capable of extracting the caller's groups and identity from an *http.Request
// the groupAuthProvider function also has discretion to deny authorization for a request by returning an error
func (s *Server) WithGroupAuth(groupAuthProvider func(*http.Request) (string, []string, error)) {
if groupAuthProvider != nil {
s.authMiddleware = groupAuthMiddleware(groupAuthProvider)
}
}

// WithCustomMiddlewares sets an array of customized gin middlewares.
// It helps for init plugins to include these customized middlewares in the api server
func (s *Server) WithCustomMiddlewares(customMiddlewares ...gin.HandlerFunc) {
Expand Down Expand Up @@ -475,18 +484,25 @@ func pingHandler(c *gin.Context) {
}

type rootOut struct {
ApplicationName string `json:"application_name"`
UserIsAdmin bool `json:"user_is_admin"`
Username string `json:"username"`
Version string `json:"version"`
Commit string `json:"commit"`
ApplicationName string `json:"application_name"`
UserIsAdmin bool `json:"user_is_admin"`
Username string `json:"username"`
UserGroups []string `json:"user_groups"`
Version string `json:"version"`
Commit string `json:"commit"`
}

func rootHandler(c *gin.Context) (*rootOut, error) {
groups := auth.GetGroups(c)
if groups == nil {
groups = []string{}
}

return &rootOut{
ApplicationName: utask.AppName(),
UserIsAdmin: auth.IsAdmin(c) == nil,
Username: auth.GetIdentity(c),
UserGroups: groups,
Version: utask.Version,
Commit: utask.Commit,
}, nil
Expand Down
44 changes: 34 additions & 10 deletions cmd/utask/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ const (
envMaintenance = "MAINTENANCE_MODE"
envLogsFormat = "LOGS_FORMAT"

basicAuthKey = "basic-auth"
basicAuthKey = "basic-auth"
groupsAuthKey = "groups-auth"
)

var (
Expand Down Expand Up @@ -140,7 +141,7 @@ var rootCmd = &cobra.Command{
}

server = api.NewServer()
server.WithAuth(defaultAuthHandler)
server.WithGroupAuth(defaultAuthHandler)

for _, err := range []error{
// register builtin executors
Expand Down Expand Up @@ -233,9 +234,31 @@ var rootCmd = &cobra.Command{
SilenceUsage: true,
}

// if a map of user passwords is found in configstore
// use them as basic auth check on incoming requests
func basicAuthHandler(store *configstore.Store) (func(*http.Request) (string, error), error) {
// basicAuthHandler handles user and groups authentication.
//
// How does it work?
// If a map of user passwords is found in configstore, use them as basic auth
// check on incoming requests. The groups of the user are determined from the
// configuration in configstore. If nothing is found, the zero value of a slice
// is returned (i.e. `nil`).
//
// It is a default implementation which can be overridden by Server.WithAuth or
// Server.WithGroupAuth functions in api package.
func basicAuthHandler(store *configstore.Store) (func(*http.Request) (string, []string, error), error) {
userGroupsMap := map[string][]string{}
groupsAuthStr, err := configstore.Filter().Slice(groupsAuthKey).Squash().Store(store).MustGetFirstItem().Value()
if err == nil {
groupsMap := map[string][]string{}
if err = json.Unmarshal([]byte(groupsAuthStr), &groupsMap); err != nil {
return nil, fmt.Errorf("failed to unmarshal utask configuration: %s", err)
}
for group, users := range groupsMap {
for _, user := range users {
userGroupsMap[user] = append(userGroupsMap[user], group)
}
}
}

authMap := map[string]string{}
basicAuthStr, err := configstore.Filter().Slice(basicAuthKey).Squash().Store(store).MustGetFirstItem().Value()
if err == nil {
Expand All @@ -249,17 +272,18 @@ func basicAuthHandler(store *configstore.Store) (func(*http.Request) (string, er
}
}
if len(authMap) > 0 {
return func(r *http.Request) (string, error) {
return func(r *http.Request) (string, []string, error) {
authHeader := r.Header.Get("Authorization")
user, found := authMap[authHeader]
if !found {
return "", errors.Unauthorizedf("User not found")
return "", nil, errors.Unauthorizedf("User not found")
}
return user, nil
return user, userGroupsMap[user], nil
}, nil
}
// fallback to expecting a username in x-remote-user header
return func(r *http.Request) (string, error) {
return r.Header.Get("x-remote-user"), nil
return func(r *http.Request) (string, []string, error) {
user := r.Header.Get("x-remote-user")
return user, userGroupsMap[user], nil
}, nil
}
15 changes: 15 additions & 0 deletions config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ postgres://user:pass@db/utask?sslmode=disable
"application_name": "µTask Foo",
// admin_usernames is a list of usernames with admin privileges over µTask resources, ie. the ability to view and execute any task, and to hotfix resolutions if a problem arises
"admin_usernames": ["admin1", "admin2"],
// admin_groups is a list of user groups with admin privileges over µTask resources, ie. the ability to view and execute any task, and to hotfix resolutions if a problem arises
"admin_groups": ["administrators", "maintainers"],
// completed_task_expiration is a textual representation of how long a task is kept in DB after its completion
"completed_task_expiration": "720h", // default == 720h == 30 days
// notify_config contains a map of named notification configurations, composed of a type and config data,
Expand Down Expand Up @@ -186,3 +188,16 @@ postgres://user:pass@db/utask?sslmode=disable
"admin": "1234"
}
```


### Groups auth

`groups-auth` key is only for development purposes: it is used to declare the users of each group.
It consists of a map of group names and slices of usernames.

```json
{
"administrators": ["admin"],
"maintainers": ["admin"]
}
```
2 changes: 1 addition & 1 deletion db/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

const (
expectedVersion = "v1.17.0-migration006"
expectedVersion = "v1.19.0-migration007"
)

var (
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ services:
CFG_DATABASE: 'postgres://user:pass@db/utask?sslmode=disable'
CFG_UTASK_CFG: '{"admin_usernames":["admin"],"application_name":"µTask"}'
CFG_BASIC_AUTH: '{"admin":"1234","resolver":"3456","regular":"5678"}'
CFG_GROUPS_AUTH: '{"admins":["admin"],"resolvers":["admin","resolver"]}'
CFG_ENCRYPTION_KEY: '{"identifier":"storage","cipher":"aes-gcm","timestamp":1535627466,"key":"e5f45aef9f072e91f735547be63f3434e6de49695b178e3868b23b0e32269800"}'
ports:
- 8081:8081
Expand Down
2 changes: 1 addition & 1 deletion engine/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ func createResolution(tmplName string, inputs, resolverInputs map[string]interfa
if err != nil {
return nil, err
}
tsk, err := task.Create(dbp, tmpl, "", nil, nil, inputs, nil, nil)
tsk, err := task.Create(dbp, tmpl, "", nil, nil, nil, nil, nil, inputs, nil, nil)
if err != nil {
return nil, err
}
Expand Down
14 changes: 14 additions & 0 deletions hack/template-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,9 +495,15 @@
"resolver_usernames": {
"type": "string"
},
"resolver_groups": {
"type": "string"
},
"watcher_usernames": {
"type": "string"
},
"watcher_groups": {
"type": "string"
},
"delay": {
"type": "string"
}
Expand Down Expand Up @@ -990,6 +996,14 @@
}
]
},
"allowed_resolver_groups": {
"type": "array",
"description": "Groups of people allowed to resolve a task based on this template",
"default": [],
"items": {
"type": "string"
}
},
"allowed_resolver_usernames": {
"type": "array",
"description": "Usernames of people allowed to resolve a task based on this template",
Expand Down
Loading

0 comments on commit 8fe2adf

Please sign in to comment.