feat!: fine-grained permissions

This commit is contained in:
Henrique Dias
2024-07-31 11:06:34 +02:00
parent f4de82cfd1
commit b5a3d07f5c
7 changed files with 176 additions and 87 deletions
+11 -9
View File
@@ -75,8 +75,10 @@ debug: false
# Default is '.' (current directory). # Default is '.' (current directory).
directory: . directory: .
# The default modification permissions for users. Default is 'false'. # The default permissions for users. This is a case insensitive option. Possible
modify: true # permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
# permissions. For example, to allow to read and create, set "RC". Default is "R".
permissions: R
# The default permissions rules for users. Default is none. # The default permissions rules for users. Default is none.
rules: [] rules: []
@@ -120,19 +122,19 @@ users:
password: "{env}ENV_PASSWORD" password: "{env}ENV_PASSWORD"
- username: basic - username: basic
password: basic password: basic
# Override default modify. # Override default permissions.
modify: false permissions: CRUD
rules: rules:
# With this rule, the user CANNOT access /some/files. # With this rule, the user CANNOT access /some/files.
- path: /some/file - path: /some/file
allow: false permissions: none
# With this rule, the user CAN modify /public/access. # With this rule, the user CAN create, read, update and delete within /public/access.
- path: /public/access/ - path: /public/access/
modify: true permissions: CRUD
# With this rule, the user CAN modify all files ending with .js. It uses # With this rule, the user CAN read and update all files ending with .js. It uses
# a regular expression. # a regular expression.
- regex: "^.+.js$" - regex: "^.+.js$"
modify: true permissions: RU
``` ```
### CORS ### CORS
+5 -5
View File
@@ -23,7 +23,7 @@ const (
) )
type Config struct { type Config struct {
Permissions `mapstructure:",squash"` UserPermissions `mapstructure:",squash"`
Debug bool Debug bool
Address string Address string
Port int Port int
@@ -74,7 +74,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
// Other defaults // Other defaults
v.SetDefault("Directory", ".") v.SetDefault("Directory", ".")
v.SetDefault("Modify", false) v.SetDefault("Permissions", "R")
v.SetDefault("Debug", false) v.SetDefault("Debug", false)
v.SetDefault("NoSniff", false) v.SetDefault("NoSniff", false)
v.SetDefault("Log.Format", "console") v.SetDefault("Log.Format", "console")
@@ -108,8 +108,8 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
cfg.Users[i].Directory = cfg.Directory cfg.Users[i].Directory = cfg.Directory
} }
if !v.IsSet(fmt.Sprintf("Users.%d.Modify", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
cfg.Users[i].Modify = cfg.Modify cfg.Users[i].Permissions = cfg.Permissions
} }
if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) { if !v.IsSet(fmt.Sprintf("Users.%d.Rules", i)) {
@@ -153,7 +153,7 @@ func (c *Config) Validate() error {
} }
} }
err = c.Permissions.Validate() err = c.UserPermissions.Validate()
if err != nil { if err != nil {
return fmt.Errorf("invalid config: %w", err) return fmt.Errorf("invalid config: %w", err)
} }
+28 -20
View File
@@ -49,17 +49,25 @@ func TestConfigCascade(t *testing.T) {
t.Parallel() t.Parallel()
check := func(t *testing.T, cfg *Config) { check := func(t *testing.T, cfg *Config) {
require.True(t, cfg.Modify) require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.False(t, cfg.Permissions.Delete)
require.False(t, cfg.Permissions.Update)
require.Equal(t, "/", cfg.Directory) require.Equal(t, "/", cfg.Directory)
require.Len(t, cfg.Rules, 1) require.Len(t, cfg.Rules, 1)
require.Len(t, cfg.Users, 2) require.Len(t, cfg.Users, 2)
require.True(t, cfg.Users[0].Permissions.Read)
require.True(t, cfg.Users[0].Modify) require.True(t, cfg.Users[0].Permissions.Create)
require.False(t, cfg.Users[0].Permissions.Delete)
require.False(t, cfg.Users[0].Permissions.Update)
require.Equal(t, "/", cfg.Users[0].Directory) require.Equal(t, "/", cfg.Users[0].Directory)
require.Len(t, cfg.Users[0].Rules, 1) require.Len(t, cfg.Users[0].Rules, 1)
require.False(t, cfg.Users[1].Modify) require.True(t, cfg.Users[1].Permissions.Read)
require.False(t, cfg.Users[1].Permissions.Create)
require.False(t, cfg.Users[1].Permissions.Delete)
require.False(t, cfg.Users[1].Permissions.Update)
require.Equal(t, "/basic", cfg.Users[1].Directory) require.Equal(t, "/basic", cfg.Users[1].Directory)
require.Len(t, cfg.Users[1].Rules, 0) require.Len(t, cfg.Users[1].Rules, 0)
} }
@@ -67,10 +75,10 @@ func TestConfigCascade(t *testing.T) {
t.Run("YAML", func(t *testing.T) { t.Run("YAML", func(t *testing.T) {
content := ` content := `
directory: / directory: /
modify: true permissions: CR
rules: rules:
- path: /public/access/ - path: /public/access/
modify: true permissions: R
users: users:
- username: admin - username: admin
@@ -78,7 +86,7 @@ users:
- username: basic - username: basic
password: basic password: basic
directory: /basic directory: /basic
modify: false permissions: R
rules: []` rules: []`
cfg := writeAndParseConfig(t, content, ".yml") cfg := writeAndParseConfig(t, content, ".yml")
@@ -90,11 +98,11 @@ users:
t.Run("JSON", func(t *testing.T) { t.Run("JSON", func(t *testing.T) {
content := `{ content := `{
"directory": "/", "directory": "/",
"modify": true, "permissions": "CR",
"rules": [ "rules": [
{ {
"path": "/public/access/", "path": "/public/access/",
"modify": true "permissions": "R"
} }
], ],
"users": [ "users": [
@@ -106,7 +114,7 @@ users:
"username": "basic", "username": "basic",
"password": "basic", "password": "basic",
"directory": "/basic", "directory": "/basic",
"modify": false, "permissions": "R",
"rules": [] "rules": []
} }
] ]
@@ -121,11 +129,11 @@ users:
t.Run("`TOML", func(t *testing.T) { t.Run("`TOML", func(t *testing.T) {
content := ` content := `
directory = "/" directory = "/"
modify = true permissions = "CR"
[[rules]] [[rules]]
path = "/public/access/" path = "/public/access/"
modify = true permissions = "R"
[[users]] [[users]]
username = "admin" username = "admin"
@@ -135,7 +143,7 @@ password = "admin"
username = "basic" username = "basic"
password = "basic" password = "basic"
directory = "/basic" directory = "/basic"
modify = false permissions = "R"
rules = [] rules = []
` `
@@ -175,12 +183,9 @@ cors:
func TestConfigRules(t *testing.T) { func TestConfigRules(t *testing.T) {
content := ` content := `
directory: / directory: /
modify: true
rules: rules:
- regex: '^.+\.js$' - regex: '^.+\.js$'
modify: true - path: /public/access/`
- path: /public/access/
modify: true`
cfg := writeAndParseConfig(t, content, ".yaml") cfg := writeAndParseConfig(t, content, ".yaml")
require.NoError(t, cfg.Validate()) require.NoError(t, cfg.Validate())
@@ -199,7 +204,7 @@ rules:
func TestConfigEnv(t *testing.T) { func TestConfigEnv(t *testing.T) {
require.NoError(t, os.Setenv("WD_PORT", "1234")) require.NoError(t, os.Setenv("WD_PORT", "1234"))
require.NoError(t, os.Setenv("WD_DEBUG", "true")) require.NoError(t, os.Setenv("WD_DEBUG", "true"))
require.NoError(t, os.Setenv("WD_MODIFY", "true")) require.NoError(t, os.Setenv("WD_PERMISSIONS", "CRUD"))
require.NoError(t, os.Setenv("WD_DIRECTORY", "/test")) require.NoError(t, os.Setenv("WD_DIRECTORY", "/test"))
cfg, err := ParseConfig("", nil) cfg, err := ParseConfig("", nil)
@@ -208,11 +213,14 @@ func TestConfigEnv(t *testing.T) {
assert.Equal(t, 1234, cfg.Port) assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, "/test", cfg.Directory) assert.Equal(t, "/test", cfg.Directory)
assert.Equal(t, true, cfg.Debug) assert.Equal(t, true, cfg.Debug)
assert.Equal(t, true, cfg.Modify) require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.True(t, cfg.Permissions.Delete)
require.True(t, cfg.Permissions.Update)
// Reset // Reset
require.NoError(t, os.Setenv("WD_PORT", "")) require.NoError(t, os.Setenv("WD_PORT", ""))
require.NoError(t, os.Setenv("WD_DEBUG", "")) require.NoError(t, os.Setenv("WD_DEBUG", ""))
require.NoError(t, os.Setenv("WD_MODIFY", "")) require.NoError(t, os.Setenv("WD_PERMISSIONS", ""))
require.NoError(t, os.Setenv("WD_DIRECTORY", "")) require.NoError(t, os.Setenv("WD_DIRECTORY", ""))
} }
+12 -2
View File
@@ -2,6 +2,8 @@ package lib
import ( import (
"net/http" "net/http"
"net/url"
"os"
"strings" "strings"
"github.com/rs/cors" "github.com/rs/cors"
@@ -23,7 +25,7 @@ func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{ h := &Handler{
user: &handlerUser{ user: &handlerUser{
User: User{ User: User{
Permissions: c.Permissions, UserPermissions: c.UserPermissions,
}, },
Handler: webdav.Handler{ Handler: webdav.Handler{
Prefix: c.Prefix, Prefix: c.Prefix,
@@ -100,7 +102,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
// Checks for user permissions relatively to this PATH. // Checks for user permissions relatively to this PATH.
allowed := user.Allowed(r) allowed := user.Allowed(r, func(destination string) bool {
u, err := url.Parse(destination)
if err != nil {
return false
}
path := strings.TrimPrefix(u.Path, user.Prefix)
_, err = user.FileSystem.Stat(r.Context(), path)
return !os.IsNotExist(err)
})
zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path)) zap.L().Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
+33 -8
View File
@@ -133,7 +133,7 @@ func TestServerAuthentication(t *testing.T) {
srv := makeTestServer(t, fmt.Sprintf(` srv := makeTestServer(t, fmt.Sprintf(`
directory: %s directory: %s
modify: true permissions: CRUD
users: users:
- username: basic - username: basic
@@ -191,39 +191,58 @@ func TestServerRules(t *testing.T) {
"a/foo.js": []byte("foo js"), "a/foo.js": []byte("foo js"),
"a/foo.txt": []byte("foo txt"), "a/foo.txt": []byte("foo txt"),
"b/foo.txt": []byte("foo b"), "b/foo.txt": []byte("foo b"),
"c/a.txt": []byte("b"),
"c/b.txt": []byte("b"),
"c/c.txt": []byte("b"),
}) })
srv := makeTestServer(t, fmt.Sprintf(` srv := makeTestServer(t, fmt.Sprintf(`
directory: %s directory: %s
modify: true permissions: CRUD
users: users:
- username: basic - username: basic
password: basic password: basic
rules: rules:
- regex: "^.+.js$" - regex: "^.+.js$"
modify: false permissions: R
- path: "/b" - path: "/b"
modify: false permissions: R
- path: "/a/foo.txt"
permissions: none
- path: "/c"
permissions: none
`, dir)) `, dir))
client := gowebdav.NewClient(srv.URL, "basic", "basic") client := gowebdav.NewClient(srv.URL, "basic", "basic")
files, err := client.ReadDir("/") files, err := client.ReadDir("/")
require.NoError(t, err) require.NoError(t, err)
require.Len(t, files, 3) require.Len(t, files, 4)
err = client.Write("/foo.txt", []byte("new"), 0666) err = client.Write("/foo.txt", []byte("new"), 0666)
require.NoError(t, err) require.NoError(t, err)
err = client.Write("/a/foo.txt", []byte("new"), 0666) err = client.Write("/new.txt", []byte("new"), 0666)
require.NoError(t, err) require.NoError(t, err)
_, err = client.Read("/a/foo.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/a/foo.js", []byte("new"), 0666) err = client.Write("/a/foo.js", []byte("new"), 0666)
require.ErrorContains(t, err, "403") require.ErrorContains(t, err, "403")
err = client.Write("/b/foo.txt", []byte("new"), 0666) err = client.Write("/b/foo.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403") require.ErrorContains(t, err, "403")
_, err = client.ReadDir("/c")
require.ErrorContains(t, err, "403")
_, err = client.Read("/c/a.txt")
require.ErrorContains(t, err, "403")
err = client.Write("/c/b.txt", []byte("new"), 0666)
require.ErrorContains(t, err, "403")
} }
func TestServerPermissions(t *testing.T) { func TestServerPermissions(t *testing.T) {
@@ -237,7 +256,7 @@ func TestServerPermissions(t *testing.T) {
srv := makeTestServer(t, fmt.Sprintf(` srv := makeTestServer(t, fmt.Sprintf(`
directory: %s directory: %s
modify: true permissions: CR
users: users:
- username: a - username: a
@@ -246,7 +265,7 @@ users:
- username: b - username: b
password: b password: b
directory: %s/b directory: %s/b
modify: false permissions: R
`, dir, dir, dir)) `, dir, dir, dir))
t.Run("User A", func(t *testing.T) { t.Run("User A", func(t *testing.T) {
@@ -265,6 +284,12 @@ users:
err = client.Copy("/foo.txt", "/copy.txt", false) err = client.Copy("/foo.txt", "/copy.txt", false)
require.NoError(t, err) require.NoError(t, err)
err = client.Copy("/foo.txt", "/copy.txt", true)
require.ErrorContains(t, err, "403")
err = client.Rename("/foo.txt", "/copy.txt", true)
require.ErrorContains(t, err, "403")
data, err = client.Read("/copy.txt") data, err = client.Read("/copy.txt")
require.NoError(t, err) require.NoError(t, err)
require.EqualValues(t, []byte("foo a"), data) require.EqualValues(t, []byte("foo a"), data)
+68 -24
View File
@@ -9,16 +9,8 @@ import (
"strings" "strings"
) )
var readMethods = []string{
http.MethodGet,
http.MethodHead,
http.MethodOptions,
"PROPFIND",
}
type Rule struct { type Rule struct {
Allow bool Permissions Permissions
Modify bool
Path string Path string
Regex *regexp.Regexp Regex *regexp.Regexp
} }
@@ -40,36 +32,27 @@ func (r *Rule) Matches(path string) bool {
return strings.HasPrefix(path, r.Path) return strings.HasPrefix(path, r.Path)
} }
type Permissions struct { type UserPermissions struct {
Directory string Directory string
Modify bool Permissions Permissions
Rules []*Rule Rules []*Rule
} }
// Allowed checks if the user has permission to access a directory/file // Allowed checks if the user has permission to access a directory/file
func (p Permissions) Allowed(r *http.Request) bool { func (p UserPermissions) Allowed(r *http.Request, destinationExists func(string) bool) bool {
// Determine whether or not it is a read or write request.
readRequest := false
for _, method := range readMethods {
if r.Method == method {
readRequest = true
break
}
}
// Go through rules beginning from the last one. // Go through rules beginning from the last one.
for i := len(p.Rules) - 1; i >= 0; i-- { for i := len(p.Rules) - 1; i >= 0; i-- {
rule := p.Rules[i] rule := p.Rules[i]
if rule.Matches(r.URL.Path) { if rule.Matches(r.URL.Path) {
return rule.Allow && (readRequest || rule.Modify) return rule.Permissions.Allowed(r, destinationExists)
} }
} }
return readRequest || p.Modify return p.Permissions.Allowed(r, destinationExists)
} }
func (p *Permissions) Validate() error { func (p *UserPermissions) Validate() error {
var err error var err error
p.Directory, err = filepath.Abs(p.Directory) p.Directory, err = filepath.Abs(p.Directory)
@@ -85,3 +68,64 @@ func (p *Permissions) Validate() error {
return nil return nil
} }
type Permissions struct {
Create bool
Read bool
Update bool
Delete bool
}
func (p *Permissions) UnmarshalText(data []byte) error {
text := strings.ToLower(string(data))
if text == "none" {
return nil
}
for _, c := range text {
switch c {
case 'c':
p.Create = true
case 'r':
p.Read = true
case 'u':
p.Update = true
case 'd':
p.Delete = true
default:
return fmt.Errorf("invalid permission: %q", c)
}
}
return nil
}
func (p Permissions) Allowed(r *http.Request, destinationExists func(string) bool) bool {
switch r.Method {
case "GET", "HEAD", "OPTIONS", "POST", "PROPFIND":
// Note: POST backend implementation just returns the same thing as GET.
return p.Read
case "MKCOL":
return p.Create
case "PROPPATCH":
return p.Update
case "PUT":
if destinationExists(r.URL.Path) {
return p.Update
} else {
return p.Create
}
case "COPY", "MOVE":
if destinationExists(r.Header.Get("Destination")) {
return p.Update
} else {
return p.Create
}
case "DELETE":
return p.Delete
case "LOCK", "UNLOCK":
return p.Create || p.Read || p.Update || p.Delete
default:
return false
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ import (
) )
type User struct { type User struct {
Permissions `mapstructure:",squash"` UserPermissions `mapstructure:",squash"`
Username string Username string
Password string Password string
} }
@@ -44,7 +44,7 @@ func (u *User) Validate() error {
} }
} }
if err := u.Permissions.Validate(); err != nil { if err := u.UserPermissions.Validate(); err != nil {
return fmt.Errorf("invalid user %q: %w", u.Username, err) return fmt.Errorf("invalid user %q: %w", u.Username, err)
} }